1
Fork 0
mirror of https://github.com/RGBCube/cinny synced 2025-07-30 16:37:46 +00:00

Change username color in chat with power level color (#2282)

* add active theme context

* add chroma js library

* add hook for accessible tag color

* disable reply user color - temporary

* render user color based on tag in room timeline

* remove default tag icons

* move accessible color function to plugins

* render user power color in reply

* increase username weight in timeline

* add default color for member power level tag

* show red slash in power color badge with no color

* show power level color in room input reply

* show power level username color in notifications

* show power level color in notification reply

* show power level color in message search

* render power level color in room pin menu

* add toggle for legacy username colors

* drop over saturation from member default color

* change border color of power color badge

* show legacy username color in direct rooms
This commit is contained in:
Ajay Bura 2025-03-23 22:09:29 +11:00 committed by GitHub
parent 7d54eef95b
commit 08e975cd8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 463 additions and 91 deletions

15
package-lock.json generated
View file

@ -24,6 +24,7 @@
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"blurhash": "2.0.4", "blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0", "browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
@ -74,6 +75,7 @@
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18", "@types/node": "18.11.18",
@ -4573,6 +4575,13 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/chroma-js": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.1.tgz",
"integrity": "sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -5730,6 +5739,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/chroma-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/classnames": { "node_modules/classnames": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",

View file

@ -35,6 +35,7 @@
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"blurhash": "2.0.4", "blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0", "browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
@ -85,6 +86,7 @@
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18", "@types/node": "18.11.18",

View file

@ -2,7 +2,6 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk'; import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder'; import { LinePlaceholder } from './placeholder';
@ -11,6 +10,8 @@ import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent'; import { useRoomEvent } from '../../hooks/useRoomEvent';
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
import colorMXID from '../../../util/colorMXID';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
@ -49,10 +50,28 @@ type ReplyProps = {
replyEventId: string; replyEventId: string;
threadRootId?: string | undefined; threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined; onClick?: MouseEventHandler | undefined;
getPowerLevel?: (userId: string) => number;
getPowerLevelTag?: GetPowerLevelTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
}; };
export const Reply = as<'div', ReplyProps>( export const Reply = as<'div', ReplyProps>(
({ room, timelineSet, replyEventId, threadRootId, onClick, ...props }, ref) => { (
{
room,
timelineSet,
replyEventId,
threadRootId,
onClick,
getPowerLevel,
getPowerLevelTag,
accessibleTagColors,
legacyUsernameColor,
...props
},
ref
) => {
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const getFromLocalTimeline = useCallback( const getFromLocalTimeline = useCallback(
() => timelineSet?.findEventById(replyEventId), () => timelineSet?.findEventById(replyEventId),
@ -62,6 +81,11 @@ export const Reply = as<'div', ReplyProps>(
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const senderPL = sender && getPowerLevel?.(sender);
const powerTag = typeof senderPL === 'number' ? getPowerLevelTag?.(senderPL) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
const fallbackBody = replyEvent?.isRedacted() ? ( const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent /> <MessageDeletedContent />
@ -79,7 +103,7 @@ export const Reply = as<'div', ReplyProps>(
)} )}
<ReplyLayout <ReplyLayout
as="button" as="button"
userColor={sender ? colorMXID(sender) : undefined} userColor={usernameColor}
username={ username={
sender && ( sender && (
<Text size="T300" truncate> <Text size="T300" truncate>

View file

@ -24,6 +24,10 @@ export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...pro
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} /> <AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
)); ));
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>( export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => ( ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text <Text

View file

@ -157,6 +157,10 @@ export const Username = style({
}, },
}); });
export const UsernameBold = style({
fontWeight: 550,
});
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',

View file

@ -9,7 +9,7 @@ type PowerColorBadgeProps = {
export const PowerColorBadge = as<'span', PowerColorBadgeProps>( export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => ( ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
<AsPowerColorBadge <AsPowerColorBadge
className={classNames(css.PowerColorBadge, className)} className={classNames(css.PowerColorBadge, { [css.PowerColorBadgeNone]: !color }, className)}
style={{ style={{
backgroundColor: color, backgroundColor: color,
...style, ...style,

View file

@ -3,13 +3,30 @@ import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds'; import { color, config, DefaultReset, toRem } from 'folds';
export const PowerColorBadge = style({ export const PowerColorBadge = style({
display: 'inline-block', display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0, flexShrink: 0,
width: toRem(16), width: toRem(16),
height: toRem(16), height: toRem(16),
backgroundColor: color.Surface.OnContainer,
borderRadius: config.radii.Pill, borderRadius: config.radii.Pill,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
position: 'relative',
});
export const PowerColorBadgeNone = style({
selectors: {
'&::before': {
content: '',
display: 'inline-block',
width: '100%',
height: config.borderWidth.B300,
backgroundColor: color.Critical.Main,
position: 'absolute',
transform: `rotateZ(-45deg)`,
},
},
}); });
const PowerIconSize = createVar(); const PowerIconSize = createVar();

View file

@ -55,6 +55,8 @@ export function MessageSearch({
const allRooms = useRooms(mx, allRoomsAtom, mDirects); const allRooms = useRooms(mx, allRoomsAtom, mDirects);
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null); const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -297,6 +299,7 @@ export function MessageSearch({
mediaAutoLoad={mediaAutoLoad} mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview} urlPreview={urlPreview}
onOpen={navigateRoom} onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
/> />
</VirtualTile> </VirtualTile>
); );

View file

@ -25,6 +25,7 @@ import {
Reply, Reply,
Time, Time,
Username, Username,
UsernameBold,
} from '../../components/message'; } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media'; import { Image } from '../../components/media';
@ -32,13 +33,21 @@ import { ImageViewer } from '../../components/image-viewer';
import * as customHtmlCss from '../../styles/CustomHtml.css'; import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { ResultItem } from './useMessageSearch'; import { ResultItem } from './useMessageSearch';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import {
getTagIconSrc,
useAccessibleTagColors,
usePowerLevelTags,
} from '../../hooks/usePowerLevelTags';
import { useTheme } from '../../hooks/useTheme';
import { PowerIcon } from '../../components/power';
import colorMXID from '../../../util/colorMXID';
type SearchResultGroupProps = { type SearchResultGroupProps = {
room: Room; room: Room;
@ -47,6 +56,7 @@ type SearchResultGroupProps = {
mediaAutoLoad?: boolean; mediaAutoLoad?: boolean;
urlPreview?: boolean; urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void; onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean;
}; };
export function SearchResultGroup({ export function SearchResultGroup({
room, room,
@ -55,11 +65,18 @@ export function SearchResultGroup({
mediaAutoLoad, mediaAutoLoad,
urlPreview, urlPreview,
onOpen, onOpen,
legacyUsernameColor,
}: SearchResultGroupProps) { }: SearchResultGroupProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]); const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
const powerLevels = usePowerLevels(room);
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const mentionClickHandler = useMentionClickHandler(room.roomId); const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
@ -81,7 +98,15 @@ export function SearchResultGroup({
handleSpoilerClick: spoilerClickHandler, handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler, handleMentionClick: mentionClickHandler,
}), }),
[mx, room, linkifyOpts, highlightRegex, mentionClickHandler, spoilerClickHandler, useAuthentication] [
mx,
room,
linkifyOpts,
highlightRegex,
mentionClickHandler,
spoilerClickHandler,
useAuthentication,
]
); );
const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>( const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
@ -197,6 +222,17 @@ export function SearchResultGroup({
const threadRootId = const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
const senderPowerLevel = getPowerLevel(event.sender);
const powerLevelTag = getPowerLevelTag(senderPowerLevel);
const tagColor = powerLevelTag?.color
? accessibleTagColors?.get(powerLevelTag.color)
: undefined;
const tagIconSrc = powerLevelTag?.icon
? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
return ( return (
<SequenceCard <SequenceCard
key={event.event_id} key={event.event_id}
@ -212,7 +248,14 @@ export function SearchResultGroup({
userId={event.sender} userId={event.sender}
src={ src={
senderAvatarMxc senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined ? mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop'
) ?? undefined
: undefined : undefined
} }
alt={displayName} alt={displayName}
@ -224,11 +267,14 @@ export function SearchResultGroup({
> >
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Username style={{ color: colorMXID(event.sender) }}> <Box alignItems="Center" gap="200">
<Text as="span" truncate> <Username style={{ color: usernameColor }}>
<b>{displayName}</b> <Text as="span" truncate>
</Text> <UsernameBold>{displayName}</UsernameBold>
</Username> </Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={event.origin_server_ts} /> <Time ts={event.origin_server_ts} />
</Box> </Box>
<Box shrink="No" gap="200" alignItems="Center"> <Box shrink="No" gap="200" alignItems="Center">
@ -244,11 +290,14 @@ export function SearchResultGroup({
</Box> </Box>
{replyEventId && ( {replyEventId && (
<Reply <Reply
mx={mx}
room={room} room={room}
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/> />
)} )}
{renderMatrixEvent(event.type, false, event, displayName, getContent)} {renderMatrixEvent(event.type, false, event, displayName, getContent)}

View file

@ -99,7 +99,6 @@ import {
getImageMsgContent, getImageMsgContent,
getVideoMsgContent, getVideoMsgContent,
} from './msgContent'; } from './msgContent';
import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
@ -109,26 +108,44 @@ import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
import { powerLevelAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom';
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>; fileDropContainerRef: RefObject<HTMLElement>;
roomId: string; roomId: string;
room: Room; room: Room;
getPowerLevelTag: GetPowerLevelTag;
accessibleTagColors: Map<string, string>;
} }
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room }, ref) => { ({ editor, fileDropContainerRef, roomId, room, getPowerLevelTag, accessibleTagColors }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const commands = useCommands(mx, room); const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null); const emojiBtnRef = useRef<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevelsContext();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const replyUserID = replyDraft?.userId;
const replyPowerTag = getPowerLevelTag(powerLevelAPI.getPowerLevel(powerLevels, replyUserID));
const replyPowerColor = replyPowerTag.color
? accessibleTagColors.get(replyPowerTag.color)
: undefined;
const replyUsernameColor =
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true); const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
@ -348,7 +365,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleKeyDown: KeyboardEventHandler = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
if ((isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing) { if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!evt.nativeEvent.isComposing
) {
evt.preventDefault(); evt.preventDefault();
submit(); submit();
} }
@ -526,7 +546,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Box direction="Column"> <Box direction="Column">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />} {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout <ReplyLayout
userColor={colorMXID(replyDraft.userId)} userColor={replyUsernameColor}
username={ username={
<Text size="T300" truncate> <Text size="T300" truncate>
<b> <b>

View file

@ -118,6 +118,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
import { useIsDirectRoom } from '../../hooks/useRoom';
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@ -220,6 +222,8 @@ type RoomTimelineProps = {
eventId?: string; eventId?: string;
roomInputRef: RefObject<HTMLElement>; roomInputRef: RefObject<HTMLElement>;
editor: Editor; editor: Editor;
getPowerLevelTag: GetPowerLevelTag;
accessibleTagColors: Map<string, string>;
}; };
const PAGINATION_LIMIT = 80; const PAGINATION_LIMIT = 80;
@ -422,12 +426,21 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
}; };
}; };
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export function RoomTimeline({
room,
eventId,
roomInputRef,
editor,
getPowerLevelTag,
accessibleTagColors,
}: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@ -443,11 +456,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
usePowerLevelsAPI(powerLevels); usePowerLevelsAPI(powerLevels);
const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
const canRedact = canDoAction('redact', myPowerLevel); const canRedact = canDoAction('redact', myPowerLevel);
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel); const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
@ -996,6 +1011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderPowerLevel = getPowerLevel(mEvent.getSender());
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
@ -1029,6 +1045,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/> />
) )
} }
@ -1045,6 +1065,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1071,6 +1094,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const senderPowerLevel = getPowerLevel(mEvent.getSender());
return ( return (
<Message <Message
@ -1102,6 +1126,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/> />
) )
} }
@ -1118,6 +1146,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
> >
<EncryptedContent mEvent={mEvent}> <EncryptedContent mEvent={mEvent}>
{() => { {() => {
@ -1181,6 +1212,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const senderPowerLevel = getPowerLevel(mEvent.getSender());
return ( return (
<Message <Message
@ -1215,6 +1247,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />

View file

@ -21,6 +21,8 @@ import { editableActiveElement } from '../../utils/dom';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { useAccessibleTagColors, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useTheme } from '../../hooks/useTheme';
const FN_KEYS_REGEX = /^F\d+$/; const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -74,6 +76,10 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId)) ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
: false; : false;
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
useKeyDown( useKeyDown(
window, window,
useCallback( useCallback(
@ -103,6 +109,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
eventId={eventId} eventId={eventId}
roomInputRef={roomInputRef} roomInputRef={roomInputRef}
editor={editor} editor={editor}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
/> />
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
</Box> </Box>
@ -123,6 +131,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
roomId={roomId} roomId={roomId}
fileDropContainerRef={roomViewRef} fileDropContainerRef={roomViewRef}
ref={roomInputRef} ref={roomInputRef}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
/> />
)} )}
{!canMessage && ( {!canMessage && (

View file

@ -44,8 +44,8 @@ import {
ModernLayout, ModernLayout,
Time, Time,
Username, Username,
UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import colorMXID from '../../../../util/colorMXID';
import { import {
canEditEvent, canEditEvent,
getEventEdits, getEventEdits,
@ -76,6 +76,9 @@ import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { getTagIconSrc, PowerLevelTag } from '../../../hooks/usePowerLevelTags';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -672,6 +675,9 @@ export type MessageProps = {
reply?: ReactNode; reply?: ReactNode;
reactions?: ReactNode; reactions?: ReactNode;
hideReadReceipts?: boolean; hideReadReceipts?: boolean;
powerLevelTag?: PowerLevelTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
}; };
export const Message = as<'div', MessageProps>( export const Message = as<'div', MessageProps>(
( (
@ -697,6 +703,9 @@ export const Message = as<'div', MessageProps>(
reply, reply,
reactions, reactions,
hideReadReceipts, hideReadReceipts,
powerLevelTag,
accessibleTagColors,
legacyUsernameColor,
children, children,
...props ...props
}, },
@ -715,6 +724,15 @@ export const Message = as<'div', MessageProps>(
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const tagColor = powerLevelTag?.color
? accessibleTagColors?.get(powerLevelTag.color)
: undefined;
const tagIconSrc = powerLevelTag?.icon
? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const headerJSX = !collapse && ( const headerJSX = !collapse && (
<Box <Box
gap="300" gap="300"
@ -723,17 +741,24 @@ export const Message = as<'div', MessageProps>(
alignItems="Baseline" alignItems="Baseline"
grow="Yes" grow="Yes"
> >
<Username <Box alignItems="Center" gap="200">
as="button" <Username
style={{ color: colorMXID(senderId) }} as="button"
data-user-id={senderId} style={{ color: usernameColor }}
onContextMenu={onUserClick} data-user-id={senderId}
onClick={onUsernameClick} onContextMenu={onUserClick}
> onClick={onUsernameClick}
<Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate> >
<b>{senderDisplayName}</b> <Text
</Text> as="span"
</Username> size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
truncate
>
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Box shrink="No" gap="100"> <Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && ( {messageLayout === MessageLayout.Modern && hover && (
<> <>

View file

@ -38,6 +38,7 @@ import {
Reply, Reply,
Time, Time,
Username, Username,
UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
@ -49,7 +50,6 @@ import {
getStateEvent, getStateEvent,
} from '../../../utils/room'; } from '../../../utils/room';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import colorMXID from '../../../../util/colorMXID';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { import {
@ -72,6 +72,15 @@ import { VirtualTile } from '../../../components/virtualizer';
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels'; import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { ContainerColor } from '../../../styles/ContainerColor.css'; import { ContainerColor } from '../../../styles/ContainerColor.css';
import {
getTagIconSrc,
useAccessibleTagColors,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { useIsDirectRoom } from '../../../hooks/useRoom';
type PinnedMessageProps = { type PinnedMessageProps = {
room: Room; room: Room;
@ -84,6 +93,14 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
const pinnedEvent = useRoomEvent(room, eventId); const pinnedEvent = useRoomEvent(room, eventId);
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient(); const mx = useMatrixClient();
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const powerLevels = usePowerLevelsContext();
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const [unpinState, unpin] = useAsyncCallback( const [unpinState, unpin] = useAsyncCallback(
useCallback(() => { useCallback(() => {
@ -93,7 +110,7 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
pinned: content.pinned.filter((id) => id !== eventId), pinned: content.pinned.filter((id) => id !== eventId),
}; };
return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, newContent); return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, newContent);
}, [room, eventId, mx]) }, [room, eventId, mx])
); );
@ -148,6 +165,16 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender); const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
const senderPowerLevel = getPowerLevel(sender);
const powerLevelTag = getPowerLevelTag(senderPowerLevel);
const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined;
const tagIconSrc = powerLevelTag?.icon
? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
: undefined;
const usernameColor = legacyUsernameColor || direct ? colorMXID(sender) : tagColor;
return ( return (
<ModernLayout <ModernLayout
before={ before={
@ -170,11 +197,14 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
> >
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Username style={{ color: colorMXID(sender) }}> <Box alignItems="Center" gap="200">
<Text as="span" truncate> <Username style={{ color: usernameColor }}>
<b>{displayName}</b> <Text as="span" truncate>
</Text> <UsernameBold>{displayName}</UsernameBold>
</Username> </Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={pinnedEvent.getTs()} /> <Time ts={pinnedEvent.getTs()} />
</Box> </Box>
{renderOptions()} {renderOptions()}
@ -185,6 +215,10 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
replyEventId={pinnedEvent.replyEventId} replyEventId={pinnedEvent.replyEventId}
threadRootId={pinnedEvent.threadRootId} threadRootId={pinnedEvent.threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/> />
)} )}
{renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}

View file

@ -514,6 +514,10 @@ function SelectMessageSpacing() {
} }
function Messages() { function Messages() {
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom,
'legacyUsernameColor'
);
const [hideMembershipEvents, setHideMembershipEvents] = useSetting( const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom, settingsAtom,
'hideMembershipEvents' 'hideMembershipEvents'
@ -536,6 +540,18 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} /> <SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Legacy Username Color"
after={
<Switch
variant="Primary"
value={legacyUsernameColor}
onChange={setLegacyUsernameColor}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Hide Membership Change" title="Hide Membership Change"

View file

@ -4,6 +4,8 @@ import { IPowerLevels } from './usePowerLevels';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { IImageInfo } from '../../types/matrix/common'; import { IImageInfo } from '../../types/matrix/common';
import { ThemeKind } from './useTheme';
import { accessibleColor } from '../plugins/color';
export type PowerLevelTagIcon = { export type PowerLevelTagIcon = {
key?: string; key?: string;
@ -63,7 +65,7 @@ const DEFAULT_TAGS: PowerLevelTags = {
}, },
100: { 100: {
name: 'Admin', name: 'Admin',
color: '#a000e4', color: '#0088ff',
}, },
50: { 50: {
name: 'Moderator', name: 'Moderator',
@ -71,9 +73,11 @@ const DEFAULT_TAGS: PowerLevelTags = {
}, },
0: { 0: {
name: 'Member', name: 'Member',
color: '#91cfdf',
}, },
[-1]: { [-1]: {
name: 'Muted', name: 'Muted',
color: '#888888',
}, },
}; };
@ -152,3 +156,24 @@ export const getTagIconSrc = (
icon?.key?.startsWith('mxc://') icon?.key?.startsWith('mxc://')
? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻' ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
: icon?.key; : icon?.key;
export const useAccessibleTagColors = (
themeKind: ThemeKind,
powerLevelTags: PowerLevelTags
): Map<string, string> => {
const accessibleColors: Map<string, string> = useMemo(() => {
const colors: Map<string, string> = new Map();
getPowers(powerLevelTags).forEach((power) => {
const tag = powerLevelTags[power];
const { color } = tag;
if (!color) return;
colors.set(color, accessibleColor(themeKind, color));
});
return colors;
}, [powerLevelTags, themeKind]);
return accessibleColors;
};

View file

@ -10,3 +10,13 @@ export function useRoom(): Room {
if (!room) throw new Error('Room not provided!'); if (!room) throw new Error('Room not provided!');
return room; return room;
} }
const IsDirectRoomContext = createContext<boolean>(false);
export const IsDirectRoomProvider = IsDirectRoomContext.Provider;
export const useIsDirectRoom = () => {
const direct = useContext(IsDirectRoomContext);
return direct;
};

View file

@ -1,7 +1,9 @@
import { lightTheme } from 'folds'; import { lightTheme } from 'folds';
import { useEffect, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, silverTheme } from '../../colors.css'; import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export enum ThemeKind { export enum ThemeKind {
Light = 'light', Light = 'light',
@ -72,3 +74,37 @@ export const useSystemThemeKind = (): ThemeKind => {
return themeKind; return themeKind;
}; };
export const useActiveTheme = (): Theme => {
const systemThemeKind = useSystemThemeKind();
const themes = useThemes();
const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [themeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
if (!systemTheme) {
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
return selectedTheme;
}
const selectedTheme =
systemThemeKind === ThemeKind.Dark
? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme
: themes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
return selectedTheme;
};
const ThemeContext = createContext<Theme | null>(null);
export const ThemeContextProvider = ThemeContext.Provider;
export const useTheme = (): Theme => {
const theme = useContext(ThemeContext);
if (!theme) {
throw new Error('No theme provided!');
}
return theme;
};

View file

@ -109,7 +109,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return null; return null;
}} }}
element={ element={
<> <AuthRouteThemeManager>
<ClientRoot> <ClientRoot>
<ClientInitStorageAtom> <ClientInitStorageAtom>
<ClientRoomsNotificationPreferences> <ClientRoomsNotificationPreferences>
@ -132,8 +132,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</ClientRoomsNotificationPreferences> </ClientRoomsNotificationPreferences>
</ClientInitStorageAtom> </ClientInitStorageAtom>
</ClientRoot> </ClientRoot>
<AuthRouteThemeManager /> </AuthRouteThemeManager>
</>
} }
> >
<Route <Route

View file

@ -1,8 +1,13 @@
import { useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { configClass, varsClass } from 'folds'; import { configClass, varsClass } from 'folds';
import { DarkTheme, LightTheme, ThemeKind, useSystemThemeKind, useThemes } from '../hooks/useTheme'; import {
import { useSetting } from '../state/hooks/settings'; DarkTheme,
import { settingsAtom } from '../state/settings'; LightTheme,
ThemeContextProvider,
ThemeKind,
useActiveTheme,
useSystemThemeKind,
} from '../hooks/useTheme';
export function UnAuthRouteThemeManager() { export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind(); const systemThemeKind = useSystemThemeKind();
@ -21,38 +26,15 @@ export function UnAuthRouteThemeManager() {
return null; return null;
} }
export function AuthRouteThemeManager() { export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const systemThemeKind = useSystemThemeKind(); const activeTheme = useActiveTheme();
const themes = useThemes();
const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [themeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
// apply normal theme if system theme is disabled
useEffect(() => { useEffect(() => {
if (!systemTheme) { document.body.className = '';
document.body.className = ''; document.body.classList.add(configClass, varsClass);
document.body.classList.add(configClass, varsClass);
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
document.body.classList.add(...selectedTheme.classNames); document.body.classList.add(...activeTheme.classNames);
} }, [activeTheme]);
}, [systemTheme, themes, themeId]);
// apply preferred system theme if system theme is enabled return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
useEffect(() => {
if (systemTheme) {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
const selectedTheme =
systemThemeKind === ThemeKind.Dark
? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme
: themes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
document.body.classList.add(...selectedTheme.classNames);
}
}, [systemTheme, systemThemeKind, themes, lightThemeId, darkThemeId]);
return null;
} }

View file

@ -1,7 +1,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms'; import { useDirectRooms } from './useDirectRooms';
@ -20,7 +20,7 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
return ( return (
<RoomProvider key={room.roomId} value={room}> <RoomProvider key={room.roomId} value={room}>
{children} <IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
</RoomProvider> </RoomProvider>
); );
} }

View file

@ -1,7 +1,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms'; import { useHomeRooms } from './useHomeRooms';
@ -28,7 +28,7 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
return ( return (
<RoomProvider key={room.roomId} value={room}> <RoomProvider key={room.roomId} value={room}>
{children} <IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
</RoomProvider> </RoomProvider>
); );
} }

View file

@ -53,8 +53,8 @@ import {
Reply, Reply,
Time, Time,
Username, Username,
UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import colorMXID from '../../../../util/colorMXID';
import { import {
factoryRenderLinkifyWithMention, factoryRenderLinkifyWithMention,
getReactCustomHtmlParser, getReactCustomHtmlParser,
@ -84,6 +84,16 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import {
getTagIconSrc,
useAccessibleTagColors,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { mDirectAtom } from '../../../state/mDirectList';
type RoomNotificationsGroup = { type RoomNotificationsGroup = {
roomId: string; roomId: string;
@ -194,6 +204,7 @@ type RoomNotificationsGroupProps = {
urlPreview?: boolean; urlPreview?: boolean;
hideActivity: boolean; hideActivity: boolean;
onOpen: (roomId: string, eventId: string) => void; onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean;
}; };
function RoomNotificationsGroupComp({ function RoomNotificationsGroupComp({
room, room,
@ -202,10 +213,18 @@ function RoomNotificationsGroupComp({
urlPreview, urlPreview,
hideActivity, hideActivity,
onOpen, onOpen,
legacyUsernameColor,
}: RoomNotificationsGroupProps) { }: RoomNotificationsGroupProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const mentionClickHandler = useMentionClickHandler(room.roomId); const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
@ -424,6 +443,17 @@ function RoomNotificationsGroupComp({
const threadRootId = const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
const senderPowerLevel = getPowerLevel(event.sender);
const powerLevelTag = getPowerLevelTag(senderPowerLevel);
const tagColor = powerLevelTag?.color
? accessibleTagColors?.get(powerLevelTag.color)
: undefined;
const tagIconSrc = powerLevelTag?.icon
? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
return ( return (
<SequenceCard <SequenceCard
key={notification.event.event_id} key={notification.event.event_id}
@ -458,11 +488,14 @@ function RoomNotificationsGroupComp({
> >
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Username style={{ color: colorMXID(event.sender) }}> <Box alignItems="Center" gap="200">
<Text as="span" truncate> <Username style={{ color: usernameColor }}>
<b>{displayName}</b> <Text as="span" truncate>
</Text> <UsernameBold>{displayName}</UsernameBold>
</Username> </Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={event.origin_server_ts} /> <Time ts={event.origin_server_ts} />
</Box> </Box>
<Box shrink="No" gap="200" alignItems="Center"> <Box shrink="No" gap="200" alignItems="Center">
@ -482,6 +515,10 @@ function RoomNotificationsGroupComp({
replyEventId={replyEventId} replyEventId={replyEventId}
threadRootId={threadRootId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/> />
)} )}
{renderMatrixEvent(event.type, false, event, displayName, getContent)} {renderMatrixEvent(event.type, false, event, displayName, getContent)}
@ -511,7 +548,9 @@ export function Notifications() {
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const mDirects = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -671,6 +710,9 @@ export function Notifications() {
urlPreview={urlPreview} urlPreview={urlPreview}
hideActivity={hideActivity} hideActivity={hideActivity}
onOpen={navigateRoom} onOpen={navigateRoom}
legacyUsernameColor={
legacyUsernameColor || mDirects.has(groupRoom.roomId)
}
/> />
</VirtualTile> </VirtualTile>
); );

View file

@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
@ -10,11 +10,13 @@ import { getAllParents } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { mDirectAtom } from '../../../state/mDirectList';
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const space = useSpace(); const space = useSpace();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { roomIdOrAlias, eventId } = useParams(); const { roomIdOrAlias, eventId } = useParams();
@ -39,7 +41,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
return ( return (
<RoomProvider key={room.roomId} value={room}> <RoomProvider key={room.roomId} value={room}>
{children} <IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider> </RoomProvider>
); );
} }

16
src/app/plugins/color.ts Normal file
View file

@ -0,0 +1,16 @@
import chroma from 'chroma-js';
import { ThemeKind } from '../hooks/useTheme';
export const accessibleColor = (themeKind: ThemeKind, color: string): string => {
if (!chroma.valid(color)) return color;
let lightness = chroma(color).lab()[0];
if (themeKind === ThemeKind.Dark && lightness < 60) {
lightness = 60;
}
if (themeKind === ThemeKind.Light && lightness > 50) {
lightness = 50;
}
return chroma(color).set('lab.l', lightness).hex();
};

View file

@ -30,6 +30,7 @@ export interface Settings {
urlPreview: boolean; urlPreview: boolean;
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean;
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
@ -59,6 +60,7 @@ const defaultSettings: Settings = {
urlPreview: true, urlPreview: true,
encUrlPreview: false, encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false,
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,