Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/app/components/room-avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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<HTMLImageElement | undefined>(undefined);
const normalizedBg = image ? bgColorImg(image) : undefined;
const useUniformIcons = uniformIconsSetting && uniformIcons === true;

const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setImage(evt.currentTarget);
};

return (
<FoldsAvatarImage
// We sample avatar pixels in bgColorImg() via canvas getImageData().
// Firefox blocks cross-origin canvas pixel reads to mitigate data exfiltration risk.
// Matrix media is cross-origin by design, so we opt into CORS mode for this image.
// https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/CORS_enabled_image
crossOrigin="anonymous"
className={css.RoomAvatar}
style={{ backgroundColor: useUniformIcons ? normalizedBg : undefined }}
src={src}
alt={alt}
onError={() => {
setImage(undefined);
onError();
}}
onLoad={handleLoad}
draggable={false}
/>
);
}
17 changes: 7 additions & 10 deletions src/app/components/room-avatar/RoomAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement> = (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 (
Expand All @@ -31,12 +30,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps

return (
<AvatarImage
className={css.RoomAvatar}
src={src}
alt={alt}
uniformIcons={uniformIcons}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export function RoomNavItem({
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
uniformIcons
src={
direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
Expand Down
11 changes: 11 additions & 0 deletions src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ function PageZoomInput() {
function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [uniformIcons, setUniformIcons] = useSetting(settingsAtom, 'uniformIcons');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');

return (
Expand Down Expand Up @@ -340,6 +341,16 @@ function Appearance() {
/>
</SequenceCard>

<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Consistent Icon Style"
description="Harmonize icon appearance with background fill"
after={
<Switch variant="Primary" value={uniformIcons} onChange={setUniformIcons} />
}
/>
</SequenceCard>

<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
Expand Down
2 changes: 2 additions & 0 deletions src/app/pages/client/sidebar/SpaceTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ function SpaceTab({
>
<RoomAvatar
roomId={space.roomId}
uniformIcons
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
Expand Down Expand Up @@ -580,6 +581,7 @@ function ClosedSpaceFolder({
<SidebarAvatar key={sId} size="200" radii="300">
<RoomAvatar
roomId={space.roomId}
uniformIcons
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
Expand Down
2 changes: 2 additions & 0 deletions src/app/state/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface Settings {
lightThemeId?: string;
darkThemeId?: string;
monochromeMode?: boolean;
uniformIcons: boolean;
isMarkdown: boolean;
editorToolbar: boolean;
twitterEmoji: boolean;
Expand Down Expand Up @@ -49,6 +50,7 @@ const defaultSettings: Settings = {
lightThemeId: undefined,
darkThemeId: undefined,
monochromeMode: false,
uniformIcons: false,
isMarkdown: true,
editorToolbar: false,
twitterEmoji: false,
Expand Down
121 changes: 121 additions & 0 deletions src/util/bgColorImg.js
Original file line number Diff line number Diff line change
@@ -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})`;
}