diff --git a/package-lock.json b/package-lock.json index 83c6fac..9781432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -4573,6 +4575,13 @@ "@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", @@ -5730,6 +5739,12 @@ "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", diff --git a/package.json b/package.json index 5927304..f849928 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 54b1849..563d1bf 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -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; + 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() ? ( @@ -79,7 +103,7 @@ export const Reply = as<'div', ReplyProps>( )} diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx index 1ce764b..ac196a5 100644 --- a/src/app/components/message/layout/Base.tsx +++ b/src/app/components/message/layout/Base.tsx @@ -24,6 +24,10 @@ export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...pro )); +export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => ( + +)); + export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>( ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => ( ( ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => ( (null); const scrollTopAnchorRef = useRef(null); const [searchParams, setSearchParams] = useSearchParams(); @@ -297,6 +299,7 @@ export function MessageSearch({ mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} onOpen={navigateRoom} + legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)} /> ); diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 29fce7b..c2e6c0a 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -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 ( - - - {displayName} - - + + + + {displayName} + + + {tagIconSrc && } + @@ -244,11 +290,14 @@ export function SearchResultGroup({ {replyEventId && ( )} {renderMatrixEvent(event.type, false, event, displayName, getContent)} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4d6ce04..501ee0d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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; roomId: string; room: Room; + getPowerLevelTag: GetPowerLevelTag; + accessibleTagColors: Map; } export const RoomInput = forwardRef( - ({ 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(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( 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( {replyDraft.relation?.rel_type === RelationType.Thread && } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2e50380..05caf4b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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; editor: Editor; + getPowerLevelTag: GetPowerLevelTag; + accessibleTagColors: Map; }; 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(); + 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() ? ( @@ -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 ( ) } @@ -1118,6 +1146,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) } hideReadReceipts={hideActivity} + powerLevelTag={getPowerLevelTag(senderPowerLevel)} + accessibleTagColors={accessibleTagColors} + legacyUsernameColor={legacyUsernameColor || direct} > {() => { @@ -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 ( {mEvent.isRedacted() ? ( diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0eb6bff..b6eebdf 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -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} /> @@ -123,6 +131,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { roomId={roomId} fileDropContainerRef={roomViewRef} ref={roomInputRef} + getPowerLevelTag={getPowerLevelTag} + accessibleTagColors={accessibleTagColors} /> )} {!canMessage && ( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index d6709a9..ae971ab 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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; + 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 && ( ( alignItems="Baseline" grow="Yes" > - - - {senderDisplayName} - - + + + + {senderDisplayName} + + + {tagIconSrc && } + {messageLayout === MessageLayout.Modern && hover && ( <> diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 2a35fc0..fdc1978 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -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 ( - - - {displayName} - - + + + + {displayName} + + + {tagIconSrc && } + {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)} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 569cd41..04e2728 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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() { } /> + + + } + /> + => { + const accessibleColors: Map = useMemo(() => { + const colors: Map = 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; +}; diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts index 3f802d4..4041887 100644 --- a/src/app/hooks/useRoom.ts +++ b/src/app/hooks/useRoom.ts @@ -10,3 +10,13 @@ export function useRoom(): Room { if (!room) throw new Error('Room not provided!'); return room; } + +const IsDirectRoomContext = createContext(false); + +export const IsDirectRoomProvider = IsDirectRoomContext.Provider; + +export const useIsDirectRoom = () => { + const direct = useContext(IsDirectRoomContext); + + return direct; +}; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index a29af01..cdbb9db 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -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(null); +export const ThemeContextProvider = ThemeContext.Provider; + +export const useTheme = (): Theme => { + const theme = useContext(ThemeContext); + if (!theme) { + throw new Error('No theme provided!'); + } + + return theme; +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 76f0460..3c5f40c 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -109,7 +109,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - <> + @@ -132,8 +132,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - + } > { - if (!systemTheme) { - document.body.className = ''; - document.body.classList.add(configClass, varsClass); - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; + document.body.className = ''; + document.body.classList.add(configClass, varsClass); - document.body.classList.add(...selectedTheme.classNames); - } - }, [systemTheme, themes, themeId]); + document.body.classList.add(...activeTheme.classNames); + }, [activeTheme]); - // 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.classList.add(...selectedTheme.classNames); - } - }, [systemTheme, systemThemeKind, themes, lightThemeId, darkThemeId]); - - return null; + return {children}; } diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index ca45aa1..7c26ec5 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index aa14d15..4e16f79 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index c28b675..80ce25a 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -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 ( - - - {displayName} - - + + + + {displayName} + + + {tagIconSrc && } + @@ -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) + } /> ); diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 0f13f93..a963213 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/app/plugins/color.ts b/src/app/plugins/color.ts new file mode 100644 index 0000000..47c7317 --- /dev/null +++ b/src/app/plugins/color.ts @@ -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(); +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9d97919..799747a 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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,