Change username color in chat with power level color (#2282)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 23 Mar 2025 11:09:29 +0000 (22:09 +1100)
committerGitHub <noreply@github.com>
Sun, 23 Mar 2025 11:09:29 +0000 (22:09 +1100)
* 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

26 files changed:
package-lock.json
package.json
src/app/components/message/Reply.tsx
src/app/components/message/layout/Base.tsx
src/app/components/message/layout/layout.css.ts
src/app/components/power/PowerColorBadge.tsx
src/app/components/power/style.css.ts
src/app/features/message-search/MessageSearch.tsx
src/app/features/message-search/SearchResultGroup.tsx
src/app/features/room/RoomInput.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomView.tsx
src/app/features/room/message/Message.tsx
src/app/features/room/room-pin-menu/RoomPinMenu.tsx
src/app/features/settings/general/General.tsx
src/app/hooks/usePowerLevelTags.ts
src/app/hooks/useRoom.ts
src/app/hooks/useTheme.ts
src/app/pages/Router.tsx
src/app/pages/ThemeManager.tsx
src/app/pages/client/direct/RoomProvider.tsx
src/app/pages/client/home/RoomProvider.tsx
src/app/pages/client/inbox/Notifications.tsx
src/app/pages/client/space/RoomProvider.tsx
src/app/plugins/color.ts [new file with mode: 0644]
src/app/state/settings.ts

index 83c6facd35f733a9639bebeb0365819e0f0ce4a8..9781432e075590cfd1ed4e1ac15ad01b1666310a 100644 (file)
@@ -24,6 +24,7 @@
         "await-to-js": "3.0.0",
         "blurhash": "2.0.4",
         "browser-encrypt-attachment": "0.3.0",
+        "chroma-js": "3.1.2",
         "classnames": "2.3.2",
         "dateformat": "5.0.3",
         "dayjs": "1.11.10",
@@ -74,6 +75,7 @@
         "@esbuild-plugins/node-globals-polyfill": "0.2.3",
         "@rollup/plugin-inject": "5.0.3",
         "@rollup/plugin-wasm": "6.1.1",
+        "@types/chroma-js": "3.1.1",
         "@types/file-saver": "2.0.5",
         "@types/is-hotkey": "0.1.10",
         "@types/node": "18.11.18",
         "@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": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
         "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": {
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
index 59273043cbe87b3011822d1402a537c28609fe41..f8499289362b6dd9840115f3dab936b284ea48b1 100644 (file)
@@ -35,6 +35,7 @@
     "await-to-js": "3.0.0",
     "blurhash": "2.0.4",
     "browser-encrypt-attachment": "0.3.0",
+    "chroma-js": "3.1.2",
     "classnames": "2.3.2",
     "dateformat": "5.0.3",
     "dayjs": "1.11.10",
@@ -85,6 +86,7 @@
     "@esbuild-plugins/node-globals-polyfill": "0.2.3",
     "@rollup/plugin-inject": "5.0.3",
     "@rollup/plugin-wasm": "6.1.1",
+    "@types/chroma-js": "3.1.1",
     "@types/file-saver": "2.0.5",
     "@types/is-hotkey": "0.1.10",
     "@types/node": "18.11.18",
index 54b1849fb0d894a1f83993648589b4fa52285202..563d1bf8ed941b7738b5ed93df9b95584f87c8fc 100644 (file)
@@ -2,7 +2,6 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
 import { EventTimelineSet, Room } from 'matrix-js-sdk';
 import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
 import classNames from 'classnames';
-import colorMXID from '../../../util/colorMXID';
 import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
 import { getMxIdLocalPart } from '../../utils/matrix';
 import { LinePlaceholder } from './placeholder';
@@ -11,6 +10,8 @@ import * as css from './Reply.css';
 import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
 import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 import { useRoomEvent } from '../../hooks/useRoomEvent';
+import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
+import colorMXID from '../../../util/colorMXID';
 
 type ReplyLayoutProps = {
   userColor?: string;
@@ -49,10 +50,28 @@ type ReplyProps = {
   replyEventId: string;
   threadRootId?: string | undefined;
   onClick?: MouseEventHandler | undefined;
+  getPowerLevel?: (userId: string) => number;
+  getPowerLevelTag?: GetPowerLevelTag;
+  accessibleTagColors?: Map<string, string>;
+  legacyUsernameColor?: boolean;
 };
 
 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 getFromLocalTimeline = useCallback(
       () => timelineSet?.findEventById(replyEventId),
@@ -62,6 +81,11 @@ export const Reply = as<'div', ReplyProps>(
 
     const { body } = replyEvent?.getContent() ?? {};
     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() ? (
       <MessageDeletedContent />
@@ -79,7 +103,7 @@ export const Reply = as<'div', ReplyProps>(
         )}
         <ReplyLayout
           as="button"
-          userColor={sender ? colorMXID(sender) : undefined}
+          userColor={usernameColor}
           username={
             sender && (
               <Text size="T300" truncate>
index 1ce764b516c201920bc43af5e1ddbdc31a963356..ac196a5b775b0dec080b13162475494202775036 100644 (file)
@@ -24,6 +24,10 @@ export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...pro
   <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 }>(
   ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
     <Text
index a1d45679ef3b2e2621831c499acb41d50fa4079b..a9b3f35fd6281523f87caf611f61c390caa0d8af 100644 (file)
@@ -157,6 +157,10 @@ export const Username = style({
   },
 });
 
+export const UsernameBold = style({
+  fontWeight: 550,
+});
+
 export const MessageTextBody = recipe({
   base: {
     wordBreak: 'break-word',
index a1df01268f13a93fd65978a50aaaad56a378f963..497385b7be56980b4a696bf50c294aa001309b9a 100644 (file)
@@ -9,7 +9,7 @@ type PowerColorBadgeProps = {
 export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
   ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
     <AsPowerColorBadge
-      className={classNames(css.PowerColorBadge, className)}
+      className={classNames(css.PowerColorBadge, { [css.PowerColorBadgeNone]: !color }, className)}
       style={{
         backgroundColor: color,
         ...style,
index bf752987fe274ad687c7275d80e692b86f4671dd..60737f8cb81829c657d6c638a02ce8b4dd3defeb 100644 (file)
@@ -3,13 +3,30 @@ import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
 import { color, config, DefaultReset, toRem } from 'folds';
 
 export const PowerColorBadge = style({
-  display: 'inline-block',
+  display: 'inline-flex',
+  alignItems: 'center',
+  justifyContent: 'center',
   flexShrink: 0,
   width: toRem(16),
   height: toRem(16),
-  backgroundColor: color.Surface.OnContainer,
   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();
index 5793ed911638c47131697488da14b90a137d0733..8eae4aeaac394cb7f031a6254e9b188b45622ca0 100644 (file)
@@ -55,6 +55,8 @@ export function MessageSearch({
   const allRooms = useRooms(mx, allRoomsAtom, mDirects);
   const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
   const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+
   const searchInputRef = useRef<HTMLInputElement>(null);
   const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
   const [searchParams, setSearchParams] = useSearchParams();
@@ -297,6 +299,7 @@ export function MessageSearch({
                     mediaAutoLoad={mediaAutoLoad}
                     urlPreview={urlPreview}
                     onOpen={navigateRoom}
+                    legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
                   />
                 </VirtualTile>
               );
index 29fce7bfa6eb71e300a23e4181fed57d74cf9f60..c2e6c0a1d25e532196adb439594541e78b42cf1a 100644 (file)
@@ -25,6 +25,7 @@ import {
   Reply,
   Time,
   Username,
+  UsernameBold,
 } from '../../components/message';
 import { RenderMessageContent } from '../../components/RenderMessageContent';
 import { Image } from '../../components/media';
@@ -32,13 +33,21 @@ import { ImageViewer } from '../../components/image-viewer';
 import * as customHtmlCss from '../../styles/CustomHtml.css';
 import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
-import colorMXID from '../../../util/colorMXID';
 import { ResultItem } from './useMessageSearch';
 import { SequenceCard } from '../../components/sequence-card';
 import { UserAvatar } from '../../components/user-avatar';
 import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
 import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
 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 = {
   room: Room;
@@ -47,6 +56,7 @@ type SearchResultGroupProps = {
   mediaAutoLoad?: boolean;
   urlPreview?: boolean;
   onOpen: (roomId: string, eventId: string) => void;
+  legacyUsernameColor?: boolean;
 };
 export function SearchResultGroup({
   room,
@@ -55,11 +65,18 @@ export function SearchResultGroup({
   mediaAutoLoad,
   urlPreview,
   onOpen,
+  legacyUsernameColor,
 }: SearchResultGroupProps) {
   const mx = useMatrixClient();
   const useAuthentication = useMediaAuthentication();
   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 spoilerClickHandler = useSpoilerClickHandler();
 
@@ -81,7 +98,15 @@ export function SearchResultGroup({
         handleSpoilerClick: spoilerClickHandler,
         handleMentionClick: mentionClickHandler,
       }),
-    [mx, room, linkifyOpts, highlightRegex, mentionClickHandler, spoilerClickHandler, useAuthentication]
+    [
+      mx,
+      room,
+      linkifyOpts,
+      highlightRegex,
+      mentionClickHandler,
+      spoilerClickHandler,
+      useAuthentication,
+    ]
   );
 
   const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
@@ -197,6 +222,17 @@ export function SearchResultGroup({
           const threadRootId =
             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 (
             <SequenceCard
               key={event.event_id}
@@ -212,7 +248,14 @@ export function SearchResultGroup({
                         userId={event.sender}
                         src={
                           senderAvatarMxc
-                            ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
+                            ? mxcUrlToHttp(
+                                mx,
+                                senderAvatarMxc,
+                                useAuthentication,
+                                48,
+                                48,
+                                'crop'
+                              ) ?? undefined
                             : undefined
                         }
                         alt={displayName}
@@ -224,11 +267,14 @@ export function SearchResultGroup({
               >
                 <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
                   <Box gap="200" alignItems="Baseline">
-                    <Username style={{ color: colorMXID(event.sender) }}>
-                      <Text as="span" truncate>
-                        <b>{displayName}</b>
-                      </Text>
-                    </Username>
+                    <Box alignItems="Center" gap="200">
+                      <Username style={{ color: usernameColor }}>
+                        <Text as="span" truncate>
+                          <UsernameBold>{displayName}</UsernameBold>
+                        </Text>
+                      </Username>
+                      {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
+                    </Box>
                     <Time ts={event.origin_server_ts} />
                   </Box>
                   <Box shrink="No" gap="200" alignItems="Center">
@@ -244,11 +290,14 @@ export function SearchResultGroup({
                 </Box>
                 {replyEventId && (
                   <Reply
-                    mx={mx}
                     room={room}
                     replyEventId={replyEventId}
                     threadRootId={threadRootId}
                     onClick={handleOpenClick}
+                    getPowerLevel={getPowerLevel}
+                    getPowerLevelTag={getPowerLevelTag}
+                    accessibleTagColors={accessibleTagColors}
+                    legacyUsernameColor={legacyUsernameColor}
                   />
                 )}
                 {renderMatrixEvent(event.type, false, event, displayName, getContent)}
index 4d6ce04c96f4696dae97ccd8fe6a9203130bd234..501ee0dc2e6b0642f98cb43c2f298cabe4371957 100644 (file)
@@ -99,7 +99,6 @@ import {
   getImageMsgContent,
   getVideoMsgContent,
 } from './msgContent';
-import colorMXID from '../../../util/colorMXID';
 import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
 import { CommandAutocomplete } from './CommandAutocomplete';
 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 { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 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 {
   editor: Editor;
   fileDropContainerRef: RefObject<HTMLElement>;
   roomId: string;
   room: Room;
+  getPowerLevelTag: GetPowerLevelTag;
+  accessibleTagColors: Map<string, string>;
 }
 export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
-  ({ editor, fileDropContainerRef, roomId, room }, ref) => {
+  ({ editor, fileDropContainerRef, roomId, room, getPowerLevelTag, accessibleTagColors }, ref) => {
     const mx = useMatrixClient();
     const useAuthentication = useMediaAuthentication();
     const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
     const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
     const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
+    const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+    const direct = useIsDirectRoom();
     const commands = useCommands(mx, room);
     const emojiBtnRef = useRef<HTMLButtonElement>(null);
     const roomToParents = useAtomValue(roomToParentsAtom);
+    const powerLevels = usePowerLevelsContext();
 
     const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(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 [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
     const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
@@ -348,7 +365,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 
     const handleKeyDown: KeyboardEventHandler = useCallback(
       (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();
           submit();
         }
@@ -526,7 +546,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                   <Box direction="Column">
                     {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
                     <ReplyLayout
-                      userColor={colorMXID(replyDraft.userId)}
+                      userColor={replyUsernameColor}
                       username={
                         <Text size="T300" truncate>
                           <b>
index 2e50380e4dc3250eb06c9a7910b4c5dcb57c407e..05caf4b009c409c780a7b062a1d0e44416bde34e 100644 (file)
@@ -118,6 +118,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 import { useImagePackRooms } from '../../hooks/useImagePackRooms';
+import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { useIsDirectRoom } from '../../hooks/useRoom';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -220,6 +222,8 @@ type RoomTimelineProps = {
   eventId?: string;
   roomInputRef: RefObject<HTMLElement>;
   editor: Editor;
+  getPowerLevelTag: GetPowerLevelTag;
+  accessibleTagColors: Map<string, string>;
 };
 
 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 useAuthentication = useMediaAuthentication();
   const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
   const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
   const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+  const direct = useIsDirectRoom();
   const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
   const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
   const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@@ -443,11 +456,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   const powerLevels = usePowerLevelsContext();
   const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
     usePowerLevelsAPI(powerLevels);
+
   const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
   const canRedact = canDoAction('redact', myPowerLevel);
   const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
   const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
   const [editId, setEditId] = useState<string>();
+
   const roomToParents = useAtomValue(roomToParentsAtom);
   const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
   const { navigateRoom } = useRoomNavigate();
@@ -996,6 +1011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
 
         const senderId = mEvent.getSender() ?? '';
+        const senderPowerLevel = getPowerLevel(mEvent.getSender());
         const senderDisplayName =
           getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
 
@@ -1029,6 +1045,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                   replyEventId={replyEventId}
                   threadRootId={threadRootId}
                   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}
+            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+            accessibleTagColors={accessibleTagColors}
+            legacyUsernameColor={legacyUsernameColor || direct}
           >
             {mEvent.isRedacted() ? (
               <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 { replyEventId, threadRootId } = mEvent;
         const highlighted = focusItem?.index === item && focusItem.highlight;
+        const senderPowerLevel = getPowerLevel(mEvent.getSender());
 
         return (
           <Message
@@ -1102,6 +1126,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                   replyEventId={replyEventId}
                   threadRootId={threadRootId}
                   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}
+            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+            accessibleTagColors={accessibleTagColors}
+            legacyUsernameColor={legacyUsernameColor || direct}
           >
             <EncryptedContent mEvent={mEvent}>
               {() => {
@@ -1181,6 +1212,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
         const hasReactions = reactions && reactions.length > 0;
         const highlighted = focusItem?.index === item && focusItem.highlight;
+        const senderPowerLevel = getPowerLevel(mEvent.getSender());
 
         return (
           <Message
@@ -1215,6 +1247,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
               )
             }
             hideReadReceipts={hideActivity}
+            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+            accessibleTagColors={accessibleTagColors}
+            legacyUsernameColor={legacyUsernameColor || direct}
           >
             {mEvent.isRedacted() ? (
               <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
index 0eb6bff1c09b4a9d9962f8bce96bcd782cfc7aa6..b6eebdf2308a8db65134e607eaadf4b328568ff2 100644 (file)
@@ -21,6 +21,8 @@ import { editableActiveElement } from '../../utils/dom';
 import navigation from '../../../client/state/navigation';
 import { settingsAtom } from '../../state/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 shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@@ -74,6 +76,10 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
     ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
     : false;
 
+  const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+  const theme = useTheme();
+  const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
+
   useKeyDown(
     window,
     useCallback(
@@ -103,6 +109,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
           eventId={eventId}
           roomInputRef={roomInputRef}
           editor={editor}
+          getPowerLevelTag={getPowerLevelTag}
+          accessibleTagColors={accessibleTagColors}
         />
         <RoomViewTyping room={room} />
       </Box>
@@ -123,6 +131,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
                   roomId={roomId}
                   fileDropContainerRef={roomViewRef}
                   ref={roomInputRef}
+                  getPowerLevelTag={getPowerLevelTag}
+                  accessibleTagColors={accessibleTagColors}
                 />
               )}
               {!canMessage && (
index d6709a97c488689a3ba8a4147afec0def805de56..ae971ab81214c261eba4aad17aaf1c0a78278323 100644 (file)
@@ -44,8 +44,8 @@ import {
   ModernLayout,
   Time,
   Username,
+  UsernameBold,
 } from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
 import {
   canEditEvent,
   getEventEdits,
@@ -76,6 +76,9 @@ import { getViaServers } from '../../../plugins/via-servers';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
 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;
 
@@ -672,6 +675,9 @@ export type MessageProps = {
   reply?: ReactNode;
   reactions?: ReactNode;
   hideReadReceipts?: boolean;
+  powerLevelTag?: PowerLevelTag;
+  accessibleTagColors?: Map<string, string>;
+  legacyUsernameColor?: boolean;
 };
 export const Message = as<'div', MessageProps>(
   (
@@ -697,6 +703,9 @@ export const Message = as<'div', MessageProps>(
       reply,
       reactions,
       hideReadReceipts,
+      powerLevelTag,
+      accessibleTagColors,
+      legacyUsernameColor,
       children,
       ...props
     },
@@ -715,6 +724,15 @@ export const Message = as<'div', MessageProps>(
       getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? 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 && (
       <Box
         gap="300"
@@ -723,17 +741,24 @@ export const Message = as<'div', MessageProps>(
         alignItems="Baseline"
         grow="Yes"
       >
-        <Username
-          as="button"
-          style={{ color: colorMXID(senderId) }}
-          data-user-id={senderId}
-          onContextMenu={onUserClick}
-          onClick={onUsernameClick}
-        >
-          <Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
-            <b>{senderDisplayName}</b>
-          </Text>
-        </Username>
+        <Box alignItems="Center" gap="200">
+          <Username
+            as="button"
+            style={{ color: usernameColor }}
+            data-user-id={senderId}
+            onContextMenu={onUserClick}
+            onClick={onUsernameClick}
+          >
+            <Text
+              as="span"
+              size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
+              truncate
+            >
+              <UsernameBold>{senderDisplayName}</UsernameBold>
+            </Text>
+          </Username>
+          {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
+        </Box>
         <Box shrink="No" gap="100">
           {messageLayout === MessageLayout.Modern && hover && (
             <>
index 2a35fc09ae77a4d86a32729a75a119ed89c3c057..fdc1978409012b985f68e76e2a42c5088e53bf88 100644 (file)
@@ -38,6 +38,7 @@ import {
   Reply,
   Time,
   Username,
+  UsernameBold,
 } from '../../../components/message';
 import { UserAvatar } from '../../../components/user-avatar';
 import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
@@ -49,7 +50,6 @@ import {
   getStateEvent,
 } from '../../../utils/room';
 import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
-import colorMXID from '../../../../util/colorMXID';
 import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
 import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
 import {
@@ -72,6 +72,15 @@ import { VirtualTile } from '../../../components/virtualizer';
 import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels';
 import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 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 = {
   room: Room;
@@ -84,6 +93,14 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
   const pinnedEvent = useRoomEvent(room, eventId);
   const useAuthentication = useMediaAuthentication();
   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(
     useCallback(() => {
@@ -93,7 +110,7 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
         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])
   );
 
@@ -148,6 +165,16 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
   const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
   const senderAvatarMxc = getMemberAvatarMxc(room, sender);
   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 (
     <ModernLayout
       before={
@@ -170,11 +197,14 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
     >
       <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
         <Box gap="200" alignItems="Baseline">
-          <Username style={{ color: colorMXID(sender) }}>
-            <Text as="span" truncate>
-              <b>{displayName}</b>
-            </Text>
-          </Username>
+          <Box alignItems="Center" gap="200">
+            <Username style={{ color: usernameColor }}>
+              <Text as="span" truncate>
+                <UsernameBold>{displayName}</UsernameBold>
+              </Text>
+            </Username>
+            {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
+          </Box>
           <Time ts={pinnedEvent.getTs()} />
         </Box>
         {renderOptions()}
@@ -185,6 +215,10 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
           replyEventId={pinnedEvent.replyEventId}
           threadRootId={pinnedEvent.threadRootId}
           onClick={handleOpenClick}
+          getPowerLevel={getPowerLevel}
+          getPowerLevelTag={getPowerLevelTag}
+          accessibleTagColors={accessibleTagColors}
+          legacyUsernameColor={legacyUsernameColor}
         />
       )}
       {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}
index 569cd41008489bc95ba9590f495ca3d0e40b6070..04e2728b504d489f62d898a60e54dfc93fe9da6d 100644 (file)
@@ -514,6 +514,10 @@ function SelectMessageSpacing() {
 }
 
 function Messages() {
+  const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
+    settingsAtom,
+    'legacyUsernameColor'
+  );
   const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
     settingsAtom,
     'hideMembershipEvents'
@@ -536,6 +540,18 @@ function Messages() {
       <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
         <SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
       </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">
         <SettingTile
           title="Hide Membership Change"
index e91bacbcc7c5d153cde8a589b4df29555161859b..bdcb9bcc272b4f8a470b02f1cfb29ed261498f10 100644 (file)
@@ -4,6 +4,8 @@ import { IPowerLevels } from './usePowerLevels';
 import { useStateEvent } from './useStateEvent';
 import { StateEvent } from '../../types/matrix/room';
 import { IImageInfo } from '../../types/matrix/common';
+import { ThemeKind } from './useTheme';
+import { accessibleColor } from '../plugins/color';
 
 export type PowerLevelTagIcon = {
   key?: string;
@@ -63,7 +65,7 @@ const DEFAULT_TAGS: PowerLevelTags = {
   },
   100: {
     name: 'Admin',
-    color: '#a000e4',
+    color: '#0088ff',
   },
   50: {
     name: 'Moderator',
@@ -71,9 +73,11 @@ const DEFAULT_TAGS: PowerLevelTags = {
   },
   0: {
     name: 'Member',
+    color: '#91cfdf',
   },
   [-1]: {
     name: 'Muted',
+    color: '#888888',
   },
 };
 
@@ -152,3 +156,24 @@ export const getTagIconSrc = (
   icon?.key?.startsWith('mxc://')
     ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
     : 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;
+};
index 3f802d4320dc8a329526743b4caf42d2d2f1d463..4041887dc6776e8bd2331b8698c793dc0b6ff7e5 100644 (file)
@@ -10,3 +10,13 @@ export function useRoom(): Room {
   if (!room) throw new Error('Room not provided!');
   return room;
 }
+
+const IsDirectRoomContext = createContext<boolean>(false);
+
+export const IsDirectRoomProvider = IsDirectRoomContext.Provider;
+
+export const useIsDirectRoom = () => {
+  const direct = useContext(IsDirectRoomContext);
+
+  return direct;
+};
index a29af0111232db00c991bc0ad3e399b983124729..cdbb9dba31519a93598644a27cf32ece2f90f22c 100644 (file)
@@ -1,7 +1,9 @@
 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 { butterTheme, darkTheme, silverTheme } from '../../colors.css';
+import { settingsAtom } from '../state/settings';
+import { useSetting } from '../state/hooks/settings';
 
 export enum ThemeKind {
   Light = 'light',
@@ -72,3 +74,37 @@ export const useSystemThemeKind = (): 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;
+};
index 76f0460f9608895dd64952911ebf94f4a6f8c7be..3c5f40c39a42774212ef6b3c21773f79a630941b 100644 (file)
@@ -109,7 +109,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
           return null;
         }}
         element={
-          <>
+          <AuthRouteThemeManager>
             <ClientRoot>
               <ClientInitStorageAtom>
                 <ClientRoomsNotificationPreferences>
@@ -132,8 +132,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
                 </ClientRoomsNotificationPreferences>
               </ClientInitStorageAtom>
             </ClientRoot>
-            <AuthRouteThemeManager />
-          </>
+          </AuthRouteThemeManager>
         }
       >
         <Route
index cabd8c6c68743d1c34aeb69fa75d238bf866fdf3..7e6039a82df7242b23d768db3a2a7afa77cee545 100644 (file)
@@ -1,8 +1,13 @@
-import { useEffect } from 'react';
+import React, { ReactNode, useEffect } from 'react';
 import { configClass, varsClass } from 'folds';
-import { DarkTheme, LightTheme, ThemeKind, useSystemThemeKind, useThemes } from '../hooks/useTheme';
-import { useSetting } from '../state/hooks/settings';
-import { settingsAtom } from '../state/settings';
+import {
+  DarkTheme,
+  LightTheme,
+  ThemeContextProvider,
+  ThemeKind,
+  useActiveTheme,
+  useSystemThemeKind,
+} from '../hooks/useTheme';
 
 export function UnAuthRouteThemeManager() {
   const systemThemeKind = useSystemThemeKind();
@@ -21,38 +26,15 @@ export function UnAuthRouteThemeManager() {
   return null;
 }
 
-export function AuthRouteThemeManager() {
-  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');
-
-  // apply normal theme if system theme is disabled
-  useEffect(() => {
-    if (!systemTheme) {
-      document.body.className = '';
-      document.body.classList.add(configClass, varsClass);
-      const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
-
-      document.body.classList.add(...selectedTheme.classNames);
-    }
-  }, [systemTheme, themes, themeId]);
+export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
+  const activeTheme = useActiveTheme();
 
-  // apply preferred system theme if system theme is enabled
   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.className = '';
+    document.body.classList.add(configClass, varsClass);
 
-      document.body.classList.add(...selectedTheme.classNames);
-    }
-  }, [systemTheme, systemThemeKind, themes, lightThemeId, darkThemeId]);
+    document.body.classList.add(...activeTheme.classNames);
+  }, [activeTheme]);
 
-  return null;
+  return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
 }
index ca45aa19c237cb79cb282800cf6f5e9f5c83bbaa..7c26ec54fa55d2a401671ceeacdd06159f58e2bb 100644 (file)
@@ -1,7 +1,7 @@
 import React, { ReactNode } from 'react';
 import { useParams } from 'react-router-dom';
 import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { RoomProvider } from '../../../hooks/useRoom';
+import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
 import { useDirectRooms } from './useDirectRooms';
@@ -20,7 +20,7 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
 
   return (
     <RoomProvider key={room.roomId} value={room}>
-      {children}
+      <IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
     </RoomProvider>
   );
 }
index aa14d1531ca952dbd3b29cd97d8c52ab508e806d..4e16f797786dcf9ff57036b65549cac191391b9c 100644 (file)
@@ -1,7 +1,7 @@
 import React, { ReactNode } from 'react';
 import { useParams } from 'react-router-dom';
 import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { RoomProvider } from '../../../hooks/useRoom';
+import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
 import { useHomeRooms } from './useHomeRooms';
@@ -28,7 +28,7 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
 
   return (
     <RoomProvider key={room.roomId} value={room}>
-      {children}
+      <IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
     </RoomProvider>
   );
 }
index c28b6753c2ef22570074bf2c19977751918becb3..80ce25a98f18db15305840bb3cd447486f37c1c0 100644 (file)
@@ -53,8 +53,8 @@ import {
   Reply,
   Time,
   Username,
+  UsernameBold,
 } from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
 import {
   factoryRenderLinkifyWithMention,
   getReactCustomHtmlParser,
@@ -84,6 +84,16 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 import { BackRouteHandler } from '../../../components/BackRouteHandler';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 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 = {
   roomId: string;
@@ -194,6 +204,7 @@ type RoomNotificationsGroupProps = {
   urlPreview?: boolean;
   hideActivity: boolean;
   onOpen: (roomId: string, eventId: string) => void;
+  legacyUsernameColor?: boolean;
 };
 function RoomNotificationsGroupComp({
   room,
@@ -202,10 +213,18 @@ function RoomNotificationsGroupComp({
   urlPreview,
   hideActivity,
   onOpen,
+  legacyUsernameColor,
 }: RoomNotificationsGroupProps) {
   const mx = useMatrixClient();
   const useAuthentication = useMediaAuthentication();
   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 spoilerClickHandler = useSpoilerClickHandler();
 
@@ -424,6 +443,17 @@ function RoomNotificationsGroupComp({
           const threadRootId =
             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 (
             <SequenceCard
               key={notification.event.event_id}
@@ -458,11 +488,14 @@ function RoomNotificationsGroupComp({
               >
                 <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
                   <Box gap="200" alignItems="Baseline">
-                    <Username style={{ color: colorMXID(event.sender) }}>
-                      <Text as="span" truncate>
-                        <b>{displayName}</b>
-                      </Text>
-                    </Username>
+                    <Box alignItems="Center" gap="200">
+                      <Username style={{ color: usernameColor }}>
+                        <Text as="span" truncate>
+                          <UsernameBold>{displayName}</UsernameBold>
+                        </Text>
+                      </Username>
+                      {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
+                    </Box>
                     <Time ts={event.origin_server_ts} />
                   </Box>
                   <Box shrink="No" gap="200" alignItems="Center">
@@ -482,6 +515,10 @@ function RoomNotificationsGroupComp({
                     replyEventId={replyEventId}
                     threadRootId={threadRootId}
                     onClick={handleOpenClick}
+                    getPowerLevel={getPowerLevel}
+                    getPowerLevelTag={getPowerLevelTag}
+                    accessibleTagColors={accessibleTagColors}
+                    legacyUsernameColor={legacyUsernameColor}
                   />
                 )}
                 {renderMatrixEvent(event.type, false, event, displayName, getContent)}
@@ -511,7 +548,9 @@ export function Notifications() {
   const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
   const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
   const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
   const screenSize = useScreenSizeContext();
+  const mDirects = useAtomValue(mDirectAtom);
 
   const { navigateRoom } = useRoomNavigate();
   const [searchParams, setSearchParams] = useSearchParams();
@@ -671,6 +710,9 @@ export function Notifications() {
                           urlPreview={urlPreview}
                           hideActivity={hideActivity}
                           onOpen={navigateRoom}
+                          legacyUsernameColor={
+                            legacyUsernameColor || mDirects.has(groupRoom.roomId)
+                          }
                         />
                       </VirtualTile>
                     );
index 0f13f933687afed6e9f9723f10b05242e0687e8a..a963213740434c2ad96c176d3a1bdd9d291bff36 100644 (file)
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
 import { useParams } from 'react-router-dom';
 import { useAtomValue } from 'jotai';
 import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { RoomProvider } from '../../../hooks/useRoom';
+import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
 import { useSpace } from '../../../hooks/useSpace';
@@ -10,11 +10,13 @@ import { getAllParents } from '../../../utils/room';
 import { roomToParentsAtom } from '../../../state/room/roomToParents';
 import { allRoomsAtom } from '../../../state/room-list/roomList';
 import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
+import { mDirectAtom } from '../../../state/mDirectList';
 
 export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
   const mx = useMatrixClient();
   const space = useSpace();
   const roomToParents = useAtomValue(roomToParentsAtom);
+  const mDirects = useAtomValue(mDirectAtom);
   const allRooms = useAtomValue(allRoomsAtom);
 
   const { roomIdOrAlias, eventId } = useParams();
@@ -39,7 +41,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
 
   return (
     <RoomProvider key={room.roomId} value={room}>
-      {children}
+      <IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
     </RoomProvider>
   );
 }
diff --git a/src/app/plugins/color.ts b/src/app/plugins/color.ts
new file mode 100644 (file)
index 0000000..47c7317
--- /dev/null
@@ -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();
+};
index 9d979195515ae1145959649a4d08195841ade104..799747ac7a82768f1b2ba59466fb1fc4133fb3da 100644 (file)
@@ -30,6 +30,7 @@ export interface Settings {
   urlPreview: boolean;
   encUrlPreview: boolean;
   showHiddenEvents: boolean;
+  legacyUsernameColor: boolean;
 
   showNotifications: boolean;
   isNotificationSounds: boolean;
@@ -59,6 +60,7 @@ const defaultSettings: Settings = {
   urlPreview: true,
   encUrlPreview: false,
   showHiddenEvents: false,
+  legacyUsernameColor: false,
 
   showNotifications: true,
   isNotificationSounds: true,