From 6785c25bef2f1566ee9267c04d76caf8423ec003 Mon Sep 17 00:00:00 2001 From: kamilwolter Date: Sun, 1 Mar 2026 18:52:47 +0100 Subject: [PATCH 1/2] consistent icon style, room avatar tile fill --- .../components/room-avatar/AvatarImage.tsx | 40 ++++++ src/app/components/room-avatar/RoomAvatar.tsx | 17 +-- src/app/features/room-nav/RoomNavItem.tsx | 1 + src/app/features/settings/general/General.tsx | 11 ++ src/app/pages/client/sidebar/SpaceTabs.tsx | 2 + src/app/state/settings.ts | 2 + src/util/bgColorImg.js | 121 ++++++++++++++++++ 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/app/components/room-avatar/AvatarImage.tsx create mode 100644 src/util/bgColorImg.js diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx new file mode 100644 index 0000000000..43525e6916 --- /dev/null +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -0,0 +1,40 @@ +import { AvatarImage as FoldsAvatarImage } from 'folds'; +import React, { ReactEventHandler, useState } from 'react'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import bgColorImg from '../../../util/bgColorImg'; +import * as css from './RoomAvatar.css'; + +type AvatarImageProps = { + src: string; + alt?: string; + uniformIcons?: boolean; + onError: () => void; +}; + +export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { + const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); + const [image, setImage] = useState(undefined); + const normalizedBg = image ? bgColorImg(image) : undefined; + const useUniformIcons = uniformIconsSetting && uniformIcons === true; + + const handleLoad: ReactEventHandler = (evt) => { + evt.currentTarget.setAttribute('data-image-loaded', 'true'); + setImage(evt.currentTarget); + }; + + return ( + { + setImage(undefined); + onError(); + }} + onLoad={handleLoad} + draggable={false} + /> + ); +} diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998d88..96900d2524 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,22 +1,21 @@ import { JoinRule } from 'matrix-js-sdk'; -import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; -import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; +import { AvatarFallback, Icon, Icons, color } from 'folds'; +import React, { ComponentProps, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; import { joinRuleToIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; +import { AvatarImage } from './AvatarImage'; type RoomAvatarProps = { roomId: string; src?: string; alt?: string; renderFallback: () => ReactNode; + uniformIcons?: boolean; }; -export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) { - const [error, setError] = useState(false); - const handleLoad: ReactEventHandler = (evt) => { - evt.currentTarget.setAttribute('data-image-loaded', 'true'); - }; +export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: RoomAvatarProps) { + const [error, setError] = useState(false); if (!src || error) { return ( @@ -31,12 +30,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps return ( setError(true)} - onLoad={handleLoad} - draggable={false} /> ); } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bff24..3a175e487b 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -270,6 +270,7 @@ export function RoomNavItem({ {showAvatar ? ( + + + } + /> + + ( @@ -580,6 +581,7 @@ function ClosedSpaceFolder({ ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 31ee6ccb97..18d54fc168 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -15,6 +15,7 @@ export interface Settings { lightThemeId?: string; darkThemeId?: string; monochromeMode?: boolean; + uniformIcons: boolean; isMarkdown: boolean; editorToolbar: boolean; twitterEmoji: boolean; @@ -49,6 +50,7 @@ const defaultSettings: Settings = { lightThemeId: undefined, darkThemeId: undefined, monochromeMode: false, + uniformIcons: false, isMarkdown: true, editorToolbar: false, twitterEmoji: false, diff --git a/src/util/bgColorImg.js b/src/util/bgColorImg.js new file mode 100644 index 0000000000..c14326ba82 --- /dev/null +++ b/src/util/bgColorImg.js @@ -0,0 +1,121 @@ +// Getting a dominant color from an IMG source, +// and darkening it a bit afterwards for contrast +export default function bgColorImg(img) { + const size = 32; + const darken = 0.8; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, size, size); + const px = ctx.getImageData(0, 0, size, size).data; + + const counts = new Uint16Array(4096); + const totals = new Uint32Array(4096 * 3); + + for (let i = 0, len = px.length; i < len; i += 4) { + if (px[i + 3] >= 16) { + const rq = Math.trunc(px[i] / 16); + const gq = Math.trunc(px[i + 1] / 16); + const bq = Math.trunc(px[i + 2] / 16); + const key = rq * 256 + gq * 16 + bq; + const j = key * 3; + counts[key] += 1; + totals[j] += px[i]; + totals[j + 1] += px[i + 1]; + totals[j + 2] += px[i + 2]; + } + } + + let bestScore = -1; + let bestKey = 0; + + for (let key = 0; key < 4096; key += 1) { + const count = counts[key]; + if (count > 0) { + const j = key * 3; + const avgR = totals[j] / count; + const avgG = totals[j + 1] / count; + const avgB = totals[j + 2] / count; + + const max = Math.max(avgR, avgG, avgB); + const min = Math.min(avgR, avgG, avgB); + const chromaNorm = (max - min) / 255; + const luma = (avgR * 0.2126 + avgG * 0.7152 + avgB * 0.0722) / 255; + const midW = Math.max(0.2, 1 - Math.abs(luma - 0.5) * 1.2); + const score = count * (1 + chromaNorm * 2) * midW; + + if (score > bestScore) { + bestScore = score; + bestKey = key; + } + } + } + + const rRaw = Math.trunc(bestKey / 256) * 16 + 8; + const gRaw = (Math.trunc(bestKey / 16) % 16) * 16 + 8; + const bRaw = (bestKey % 16) * 16 + 8; + + // Convert to HSL, scale lightness, convert back + const rN = rRaw / 255; + const gN = gRaw / 255; + const bN = bRaw / 255; + const max = Math.max(rN, gN, bN); + const min = Math.min(rN, gN, bN); + const delta = max - min; + + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (delta > 0) { + s = delta / (1 - Math.abs(2 * l - 1)); + if (max === rN) { + h = ((gN - bN) / delta) % 6; + } else if (max === gN) { + h = (bN - rN) / delta + 2; + } else { + h = (rN - gN) / delta + 4; + } + h = Math.round(h * 60); + if (h < 0) h += 360; + } + + // Darken: reduce lightness by 20%, + // keep hue & saturation intact + const lDark = l * darken; + + const c = (1 - Math.abs(2 * lDark - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lDark - c / 2; + + let r1 = 0; + let g1 = 0; + let b1 = 0; + + if (h < 60) { + r1 = c; + g1 = x; + } else if (h < 120) { + r1 = x; + g1 = c; + } else if (h < 180) { + g1 = c; + b1 = x; + } else if (h < 240) { + g1 = x; + b1 = c; + } else if (h < 300) { + r1 = x; + b1 = c; + } else { + r1 = c; + b1 = x; + } + + const r = Math.round((r1 + m) * 255); + const g = Math.round((g1 + m) * 255); + const b = Math.round((b1 + m) * 255); + + return `rgb(${r}, ${g}, ${b})`; +} From a64dfe63e428fd8bb1c63c196ebbf17c9ea88cd3 Mon Sep 17 00:00:00 2001 From: kamilwolter Date: Tue, 3 Mar 2026 09:53:52 +0100 Subject: [PATCH 2/2] firefox cors workaround --- src/app/components/room-avatar/AvatarImage.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index 43525e6916..c2ced8859a 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -25,6 +25,11 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp return (