"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",
"@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",
"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",
"@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",
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';
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;
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),
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 />
)}
<ReplyLayout
as="button"
- userColor={sender ? colorMXID(sender) : undefined}
+ userColor={usernameColor}
username={
sender && (
<Text size="T300" truncate>
<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
},
});
+export const UsernameBold = style({
+ fontWeight: 550,
+});
+
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
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,
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();
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();
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
+ legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
/>
</VirtualTile>
);
Reply,
Time,
Username,
+ UsernameBold,
} from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media';
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;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void;
+ legacyUsernameColor?: boolean;
};
export function SearchResultGroup({
room,
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();
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
- [mx, room, linkifyOpts, highlightRegex, mentionClickHandler, spoilerClickHandler, useAuthentication]
+ [
+ mx,
+ room,
+ linkifyOpts,
+ highlightRegex,
+ mentionClickHandler,
+ spoilerClickHandler,
+ useAuthentication,
+ ]
);
const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
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}
userId={event.sender}
src={
senderAvatarMxc
- ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
+ ? mxcUrlToHttp(
+ mx,
+ senderAvatarMxc,
+ useAuthentication,
+ 48,
+ 48,
+ 'crop'
+ ) ?? undefined
: undefined
}
alt={displayName}
>
<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">
</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)}
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';
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(
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();
}
<Box direction="Column">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
- userColor={colorMXID(replyDraft.userId)}
+ userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
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) => (
eventId?: string;
roomInputRef: RefObject<HTMLElement>;
editor: Editor;
+ getPowerLevelTag: GetPowerLevelTag;
+ accessibleTagColors: Map<string, string>;
};
const PAGINATION_LIMIT = 80;
};
};
-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');
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();
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;
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
+ getPowerLevel={getPowerLevel}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
)
}
hideReadReceipts={hideActivity}
+ powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor || direct}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderPowerLevel = getPowerLevel(mEvent.getSender());
return (
<Message
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
+ getPowerLevel={getPowerLevel}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
)
}
hideReadReceipts={hideActivity}
+ powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor || direct}
>
<EncryptedContent mEvent={mEvent}>
{() => {
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
)
}
hideReadReceipts={hideActivity}
+ powerLevelTag={getPowerLevelTag(senderPowerLevel)}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor || direct}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
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 => {
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
: false;
+ const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+ const theme = useTheme();
+ const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
+
useKeyDown(
window,
useCallback(
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
/>
<RoomViewTyping room={room} />
</Box>
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
/>
)}
{!canMessage && (
ModernLayout,
Time,
Username,
+ UsernameBold,
} from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
import {
canEditEvent,
getEventEdits,
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;
reply?: ReactNode;
reactions?: ReactNode;
hideReadReceipts?: boolean;
+ powerLevelTag?: PowerLevelTag;
+ accessibleTagColors?: Map<string, string>;
+ legacyUsernameColor?: boolean;
};
export const Message = as<'div', MessageProps>(
(
reply,
reactions,
hideReadReceipts,
+ powerLevelTag,
+ accessibleTagColors,
+ legacyUsernameColor,
children,
...props
},
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"
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 && (
<>
Reply,
Time,
Username,
+ UsernameBold,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
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 {
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;
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(() => {
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])
);
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={
>
<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()}
replyEventId={pinnedEvent.replyEventId}
threadRootId={pinnedEvent.threadRootId}
onClick={handleOpenClick}
+ getPowerLevel={getPowerLevel}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}
}
function Messages() {
+ const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
+ settingsAtom,
+ 'legacyUsernameColor'
+ );
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
<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"
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;
},
100: {
name: 'Admin',
- color: '#a000e4',
+ color: '#0088ff',
},
50: {
name: 'Moderator',
},
0: {
name: 'Member',
+ color: '#91cfdf',
},
[-1]: {
name: 'Muted',
+ color: '#888888',
},
};
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;
+};
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;
+};
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',
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;
+};
return null;
}}
element={
- <>
+ <AuthRouteThemeManager>
<ClientRoot>
<ClientInitStorageAtom>
<ClientRoomsNotificationPreferences>
</ClientRoomsNotificationPreferences>
</ClientInitStorageAtom>
</ClientRoot>
- <AuthRouteThemeManager />
- </>
+ </AuthRouteThemeManager>
}
>
<Route
-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();
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>;
}
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';
return (
<RoomProvider key={room.roomId} value={room}>
- {children}
+ <IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
</RoomProvider>
);
}
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';
return (
<RoomProvider key={room.roomId} value={room}>
- {children}
+ <IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
}
Reply,
Time,
Username,
+ UsernameBold,
} from '../../../components/message';
-import colorMXID from '../../../../util/colorMXID';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
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;
urlPreview?: boolean;
hideActivity: boolean;
onOpen: (roomId: string, eventId: string) => void;
+ legacyUsernameColor?: boolean;
};
function RoomNotificationsGroupComp({
room,
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();
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}
>
<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">
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
+ getPowerLevel={getPowerLevel}
+ getPowerLevelTag={getPowerLevelTag}
+ accessibleTagColors={accessibleTagColors}
+ legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
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();
urlPreview={urlPreview}
hideActivity={hideActivity}
onOpen={navigateRoom}
+ legacyUsernameColor={
+ legacyUsernameColor || mDirects.has(groupRoom.roomId)
+ }
/>
</VirtualTile>
);
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';
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();
return (
<RoomProvider key={room.roomId} value={room}>
- {children}
+ <IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
}
--- /dev/null
+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();
+};
urlPreview: boolean;
encUrlPreview: boolean;
showHiddenEvents: boolean;
+ legacyUsernameColor: boolean;
showNotifications: boolean;
isNotificationSounds: boolean;
urlPreview: true,
encUrlPreview: false,
showHiddenEvents: false,
+ legacyUsernameColor: false,
showNotifications: true,
isNotificationSounds: true,