fix: comprehensive P0 quality pass — audit findings resolved

- ReportRoomModal: use mx.reportRoom() SDK method, fix undefined CSS vars
  (--mx-surface/border → folds color tokens), add role/aria-modal/aria-labelledby,
  accessible select/input labels, per-error-code messages, auto-close on success
- About.tsx: clickable matrix_id + email_address links (Text as="a"), AbortController
  cleanup, runtime JSON type guard, loading state, role display for all role values,
  remove classList theming hack, use mx.getHomeserverUrl()
- RoomViewHeader: useLocalRoomName for header title, useReportRoomSupported gate,
  hide Invite/Settings/Report for server notice rooms, isCreator guard on Report,
  FocusTrap returnFocusOnDeactivate on topic overlay, Server Notice chip tooltip
- RoomInput: replace raw <div> with folds <Box> for server notice read-only message
- EditHistoryModal: isRawEditEvent type guard, handle next_batch truncation,
  getVersionBody handles formatted_body (strips HTML for text display),
  role/aria-modal/aria-labelledby accessibility, guard for undefined eventId,
  use config.space tokens (remove var(--mx-spacing-*) strings)
- RoomNavItem: remove duplicate getExistingContent (use exported getLocalRoomNamesContent),
  maxLength={255} on rename input, fix FocusTrap nesting (renameDialog state moved to
  RoomNavItem_, RenameRoomDialog rendered outside menu, menu closes before dialog opens),
  pencil icon opacity via config.opacity.P300
- useRoomMeta: export getLocalRoomNamesContent for reuse
- RoomIntro: useLocalRoomName, formatted topic viewer with Overlay/FocusTrap/RoomTopicViewer
- CallRoomName: useLocalRoomName for consistent rename display in call overlay
- General.tsx: fix #980000/#FF6B00 hardcoded hex → color tokens/CSS vars, URL Preview
  capitalization, improved encrypted preview warning text + Warning chip, add
  description to plain urlPreview setting
- sanitize.ts: fix hex color regex to support 3/4/6/8 digit hex (CSS4 #RGBA, #RRGGBBAA)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:30:27 -04:00
parent 3db87db03f
commit 16dddcb9f0
11 changed files with 363 additions and 213 deletions
+46 -6
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import FocusTrap from 'focus-trap-react';
import { Avatar, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
@@ -11,12 +12,14 @@ import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar'; import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt'; import { InviteUserPrompt } from '../invite-user-prompt';
import { RoomTopicViewer } from '../room-topic-viewer';
import { stopPropagation } from '../../utils/keyboard';
export type RoomIntroProps = { export type RoomIntroProps = {
room: Room; room: Room;
@@ -28,10 +31,11 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const [viewTopic, setViewTopic] = useState(false);
const createEvent = getStateEvent(room, StateEvent.RoomCreate); const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room); const name = useLocalRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
@@ -65,9 +69,45 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<Text size="H3" priority="500"> <Text size="H3" priority="500">
{name} {name}
</Text> </Text>
<Text size="T400" priority="400"> {topic ? (
{topic?.topic ?? 'This is the beginning of conversation.'} <>
</Text> {viewTopic && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Text
as="button"
type="button"
onClick={() => setViewTopic(true)}
size="T400"
priority="400"
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
>
{topic.topic}
</Text>
</>
) : (
<Text size="T400" priority="400">
This is the beginning of conversation.
</Text>
)}
{creatorName && ts && ( {creatorName && ts && (
<Text size="T200" priority="300"> <Text size="T200" priority="300">
{'Created by '} {'Created by '}
@@ -2,7 +2,7 @@ import React from 'react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { Chip, Icon, Icons, Text } from 'folds'; import { Chip, Icon, Icons, Text } from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta'; import { useLocalRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar'; import { RoomIcon } from '../../components/room-avatar';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { getAllParents, guessPerfectParent } from '../../utils/room'; import { getAllParents, guessPerfectParent } from '../../utils/room';
@@ -18,7 +18,7 @@ type CallRoomNameProps = {
}; };
export function CallRoomName({ room }: CallRoomNameProps) { export function CallRoomName({ room }: CallRoomNameProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const name = useRoomName(room); const name = useLocalRoomName(room);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
+21 -36
View File
@@ -61,7 +61,7 @@ import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPe
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { import {
LOCAL_ROOM_NAMES_KEY, LOCAL_ROOM_NAMES_KEY,
LocalRoomNamesContent, getLocalRoomNamesContent,
useHasLocalRoomName, useHasLocalRoomName,
useLocalRoomName, useLocalRoomName,
} from '../../hooks/useRoomMeta'; } from '../../hooks/useRoomMeta';
@@ -82,29 +82,15 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const getExistingContent = useCallback((): LocalRoomNamesContent => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
if (
raw &&
typeof raw === 'object' &&
'rooms' in raw &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (raw as any).rooms === 'object'
) {
return raw as LocalRoomNamesContent;
}
return { rooms: {} };
}, [mx]);
const getCurrentLocalName = useCallback((): string => { const getCurrentLocalName = useCallback((): string => {
const content = getExistingContent(); const content = getLocalRoomNamesContent(mx);
return content.rooms[room.roomId] ?? ''; return content.rooms[room.roomId] ?? '';
}, [getExistingContent, room.roomId]); }, [mx, room.roomId]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
const newName = inputRef.current?.value.trim() ?? ''; const newName = inputRef.current?.value.trim() ?? '';
const existing = getExistingContent(); if (newName.length > 255) return;
const existing = getLocalRoomNamesContent(mx);
if (newName === '') { if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms; const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -116,15 +102,15 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
}); });
} }
onClose(); onClose();
}, [mx, room.roomId, getExistingContent, onClose]); }, [mx, room.roomId, onClose]);
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
const existing = getExistingContent(); const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms; const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest }); (mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose(); onClose();
}, [mx, room.roomId, getExistingContent, onClose]); }, [mx, room.roomId, onClose]);
const handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
if (evt.key === 'Enter') handleSave(); if (evt.key === 'Enter') handleSave();
@@ -169,6 +155,7 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
placeholder={room.name} placeholder={room.name}
variant="Secondary" variant="Secondary"
radii="300" radii="300"
maxLength={255}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus autoFocus
/> />
@@ -210,10 +197,11 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
requestClose: () => void; requestClose: () => void;
onRenameClick: () => void;
notificationMode?: RoomNotificationMode; notificationMode?: RoomNotificationMode;
}; };
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>( const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
({ room, requestClose, notificationMode }, ref) => { ({ room, requestClose, onRenameClick, notificationMode }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -226,7 +214,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const [renameDialog, setRenameDialog] = useState(false);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
@@ -260,15 +247,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
}} }}
/> />
)} )}
{renameDialog && (
<RenameRoomDialog
room={room}
onClose={() => {
setRenameDialog(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@@ -330,11 +308,13 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => setRenameDialog(true)} onClick={() => {
requestClose();
onRenameClick();
}}
size="300" size="300"
after={<Icon size="100" src={Icons.Pencil} />} after={<Icon size="100" src={Icons.Pencil} />}
radii="300" radii="300"
aria-pressed={renameDialog}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Rename for me Rename for me
@@ -425,6 +405,7 @@ function RoomNavItem_({
const { hoverProps } = useHover({ onHoverChange: setHover }); const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [renameDialog, setRenameDialog] = useState(false);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId(), (receipt) => receipt.userId !== mx.getUserId(),
@@ -533,7 +514,7 @@ function RoomNavItem_({
size="50" size="50"
src={Icons.Pencil} src={Icons.Pencil}
aria-label="Custom local name" aria-label="Custom local name"
style={{ opacity: 0.6, flexShrink: 0 }} style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/> />
)} )}
</Box> </Box>
@@ -592,6 +573,7 @@ function RoomNavItem_({
<RoomNavItemMenu <RoomNavItemMenu
room={room} room={room}
requestClose={() => setMenuAnchor(undefined)} requestClose={() => setMenuAnchor(undefined)}
onRenameClick={() => setRenameDialog(true)}
notificationMode={notificationMode} notificationMode={notificationMode}
/> />
</FocusTrap> </FocusTrap>
@@ -612,6 +594,9 @@ function RoomNavItem_({
</PopOut> </PopOut>
</NavItemOptions> </NavItemOptions>
)} )}
{renameDialog && (
<RenameRoomDialog room={room} onClose={() => setRenameDialog(false)} />
)}
</NavItem> </NavItem>
); );
} }
+42 -21
View File
@@ -1,4 +1,4 @@
import React, { FormEventHandler, useCallback, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Box, Box,
@@ -16,7 +16,6 @@ import {
color, color,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import { Method } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@@ -42,17 +41,20 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
const [reportState, submitReport] = useAsyncCallback( const [reportState, submitReport] = useAsyncCallback(
useCallback( useCallback(
async (reason: string) => { async (reason: string) => {
await mx.http.authedRequest( await mx.reportRoom(roomId, reason);
Method.Post,
`/rooms/${encodeURIComponent(roomId)}/report`,
undefined,
{ reason },
);
}, },
[mx, roomId], [mx, roomId],
), ),
); );
useEffect(() => {
if (reportState.status === AsyncStatus.Success) {
const timer = setTimeout(onClose, 1500);
return () => clearTimeout(timer);
}
return undefined;
}, [reportState.status, onClose]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
if (reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success) { if (reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success) {
@@ -61,12 +63,20 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
const target = evt.target as HTMLFormElement; const target = evt.target as HTMLFormElement;
const reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null; const reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null;
const reasonText = reasonInput?.value.trim() ?? ''; const reasonText = reasonInput?.value.trim() ?? '';
const fullReason = reasonText const fullReason = `[${CATEGORY_LABELS[category]}] ${reasonText}`;
? `[${CATEGORY_LABELS[category]}] ${reasonText}`
: `[${CATEGORY_LABELS[category]}]`;
submitReport(fullReason); submitReport(fullReason);
}; };
const errcode = (reportState.status === AsyncStatus.Error
? (reportState.error as { errcode?: string })?.errcode
: undefined);
const errorMsg =
errcode === 'M_LIMIT_EXCEEDED'
? 'You are being rate limited. Please wait before reporting again.'
: errcode === 'M_FORBIDDEN'
? 'You cannot report this room.'
: 'Failed to submit report. Please try again.';
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
@@ -80,12 +90,15 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
> >
<Box <Box
as="form" as="form"
role="dialog"
aria-modal="true"
aria-labelledby="report-room-dialog-title"
onSubmit={handleSubmit} onSubmit={handleSubmit}
direction="Column" direction="Column"
style={{ style={{
background: 'var(--mx-surface)', background: color.Surface.Container,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
boxShadow: '0 8px 32px rgba(0,0,0,0.55)', boxShadow: color.Other.Shadow,
width: '100%', width: '100%',
maxWidth: 420, maxWidth: 420,
overflow: 'hidden', overflow: 'hidden',
@@ -100,7 +113,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
size="500" size="500"
> >
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Report Room</Text> <Text id="report-room-dialog-title" size="H4">Report Room</Text>
</Box> </Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close"> <IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
@@ -113,9 +126,11 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Text> </Text>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Category</Text> <Text as="label" htmlFor="report-category" size="L400">Category</Text>
<Box <Box
as="select" as="select"
id="report-category"
aria-label="Report category"
value={category} value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory) setCategory(e.target.value as ReportCategory)
@@ -123,9 +138,9 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
style={{ style={{
padding: `${config.space.S200} ${config.space.S300}`, padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
border: '1px solid var(--mx-border)', border: `1px solid ${color.Surface.ContainerLine}`,
background: 'var(--mx-bg-surface)', background: color.Surface.Container,
color: 'var(--mx-c-surface-on)', color: color.Surface.OnContainer,
fontSize: 'inherit', fontSize: 'inherit',
fontFamily: 'inherit', fontFamily: 'inherit',
width: '100%', width: '100%',
@@ -140,11 +155,17 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Reason</Text> <Text as="label" htmlFor="report-reason-input" size="L400">Reason</Text>
<Input name="reasonInput" variant="Background" required /> <Input
id="report-reason-input"
name="reasonInput"
aria-label="Reason for report"
variant="Background"
required
/>
{reportState.status === AsyncStatus.Error && ( {reportState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300"> <Text style={{ color: color.Critical.Main }} size="T300">
Failed to submit report. Please try again. {errorMsg}
</Text> </Text>
)} )}
{reportState.status === AsyncStatus.Success && ( {reportState.status === AsyncStatus.Success && (
+9 -3
View File
@@ -605,11 +605,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.getType() === 'm.server_notice') { if (room.getType() === 'm.server_notice') {
return ( return (
<div ref={ref} style={{ padding: config.space.S300, textAlign: 'center' }}> <Box
ref={ref as React.Ref<HTMLDivElement>}
direction="Column"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S300 }}
>
<Text size="T300" priority="300"> <Text size="T300" priority="300">
This is a server notice room you cannot send messages here. This room contains system messages from your homeserver. Replies are not permitted.
</Text> </Text>
</div> </Box>
); );
} }
+65 -42
View File
@@ -48,7 +48,8 @@ import { markAsRead } from '../../utils/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { useReportRoomSupported } from '../../hooks/useReportRoomSupported';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
@@ -89,6 +90,9 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const reportRoomSupported = useReportRoomSupported();
const isServerNotice = room.getType() === 'm.server_notice';
const isCreator = creators.has(mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
@@ -175,20 +179,22 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem {!isServerNotice && (
onClick={handleInvite} <MenuItem
variant="Primary" onClick={handleInvite}
fill="None" variant="Primary"
size="300" fill="None"
after={<Icon size="100" src={Icons.UserPlus} />} size="300"
radii="300" after={<Icon size="100" src={Icons.UserPlus} />}
aria-pressed={invitePrompt} radii="300"
disabled={!canInvite} aria-pressed={invitePrompt}
> disabled={!canInvite}
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> >
Invite <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
</Text> Invite
</MenuItem> </Text>
</MenuItem>
)}
<MenuItem <MenuItem
onClick={handleCopyLink} onClick={handleCopyLink}
size="300" size="300"
@@ -199,16 +205,18 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
Copy Link Copy Link
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem {!isServerNotice && (
onClick={handleOpenSettings} <MenuItem
size="300" onClick={handleOpenSettings}
after={<Icon size="100" src={Icons.Setting} />} size="300"
radii="300" after={<Icon size="100" src={Icons.Setting} />}
> radii="300"
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> >
Room Settings <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
</Text> Room Settings
</MenuItem> </Text>
</MenuItem>
)}
<UseStateProvider initial={false}> <UseStateProvider initial={false}>
{(promptJump, setPromptJump) => ( {(promptJump, setPromptJump) => (
<> <>
@@ -239,19 +247,21 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem {reportRoomSupported && !isServerNotice && !isCreator && (
onClick={() => setReportRoomOpen(true)} <MenuItem
variant="Critical" onClick={() => setReportRoomOpen(true)}
fill="None" variant="Critical"
size="300" fill="None"
after={<Icon size="100" src={Icons.Warning} />} size="300"
radii="300" after={<Icon size="100" src={Icons.Warning} />}
aria-pressed={reportRoomOpen} radii="300"
> aria-pressed={reportRoomOpen}
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> >
Report Room <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
</Text> Report Room
</MenuItem> </Text>
</MenuItem>
)}
<UseStateProvider initial={false}> <UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => ( {(promptLeave, setPromptLeave) => (
<> <>
@@ -436,7 +446,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent; const encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, direct); const avatarMxc = useRoomAvatar(room, direct);
const name = useRoomName(room); const name = useLocalRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
@@ -508,9 +518,21 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{name} {name}
</Text> </Text>
{room.getType() === 'm.server_notice' && ( {room.getType() === 'm.server_notice' && (
<Chip size="400" variant="Warning" radii="Pill" outlined> <TooltipProvider
<Text size="T200">Server Notice</Text> position="Bottom"
</Chip> offset={4}
tooltip={
<Tooltip>
<Text>System messages from your homeserver administrator.</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip ref={triggerRef} size="400" variant="Warning" radii="Pill" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
)}
</TooltipProvider>
)} )}
</Box> </Box>
{topic && ( {topic && (
@@ -522,6 +544,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false), onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
@@ -13,6 +13,7 @@ import {
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
config,
} from 'folds'; } from 'folds';
import { MatrixEvent, Method, Room } from 'matrix-js-sdk'; import { MatrixEvent, Method, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -22,17 +23,48 @@ import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
type RawEditEvent = {
type: string;
content: Record<string, unknown>;
origin_server_ts: number;
event_id: string;
};
type EditHistoryResponse = { type EditHistoryResponse = {
chunk: Array<Record<string, unknown>>; chunk: Array<Record<string, unknown>>;
next_batch?: string; next_batch?: string;
}; };
type EditHistoryData = {
events: MatrixEvent[];
hasMore: boolean;
};
type EditHistoryModalProps = { type EditHistoryModalProps = {
room: Room; room: Room;
mEvent: MatrixEvent; mEvent: MatrixEvent;
onClose: () => void; onClose: () => void;
}; };
function isRawEditEvent(raw: unknown): raw is RawEditEvent {
if (typeof raw !== 'object' || raw === null) return false;
const r = raw as Record<string, unknown>;
return typeof r.event_id === 'string' && typeof r.origin_server_ts === 'number';
}
function getVersionBody(evt: MatrixEvent): string {
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
const source = newContent ?? content;
const formattedBody = source.formatted_body;
if (typeof formattedBody === 'string') {
return formattedBody.replace(/<[^>]+>/g, '').trim() || '(no text)';
}
const body = source.body;
return typeof body === 'string' ? body : '(no text)';
}
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) { export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
@@ -41,43 +73,32 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
const eventId = mEvent.getId(); const eventId = mEvent.getId();
const roomId = room.roomId; const roomId = room.roomId;
const [historyState, fetchHistory] = useAsyncCallback<MatrixEvent[], unknown, []>( const [historyState, fetchHistory] = useAsyncCallback<EditHistoryData, unknown, []>(
useCallback(async () => { useCallback(async () => {
const path = `/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId ?? '')}/m.replace`; if (!eventId) return { events: [], hasMore: false };
const path = `/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace`;
const res = await mx.http.authedRequest<EditHistoryResponse>(Method.Get, path, { const res = await mx.http.authedRequest<EditHistoryResponse>(Method.Get, path, {
limit: '50', limit: '50',
}); });
const rawEvents = res.chunk ?? []; const rawEvents = res.chunk ?? [];
// Sort oldest first
const events = rawEvents const events = rawEvents
.map( .filter(isRawEditEvent)
(raw) => .sort((a, b) => a.origin_server_ts - b.origin_server_ts)
// Build a lightweight representation for display; we just need content + ts .map((raw) => {
raw as { const existing = room.findEventById(raw.event_id);
type: string; if (existing) return existing;
content: Record<string, unknown>; return new MatrixEvent({
origin_server_ts: number; type: raw.type,
event_id: string; content: raw.content,
}, origin_server_ts: raw.origin_server_ts,
) event_id: raw.event_id,
.sort((a, b) => a.origin_server_ts - b.origin_server_ts); room_id: roomId,
sender: mEvent.getSender() ?? '',
// Convert to MatrixEvent-like objects using the raw data });
// We use MatrixEvent if available from the timeline, otherwise parse the raw data
return events.map((raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
// Create a minimal event wrapper
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
event_id: raw.event_id,
room_id: roomId,
sender: mEvent.getSender() ?? '',
}); });
return evt;
}); return { events, hasMore: !!res.next_batch };
}, [mx, roomId, eventId, room, mEvent]), }, [mx, roomId, eventId, room, mEvent]),
); );
@@ -91,14 +112,6 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
return `${date} at ${time}`; return `${date} at ${time}`;
}; };
const getVersionBody = (evt: MatrixEvent): string => {
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
const body = newContent?.body ?? content.body;
return typeof body === 'string' ? body : '(no text)';
};
// The original message body (before any edits)
const originalBody = (() => { const originalBody = (() => {
const content = mEvent.getContent(); const content = mEvent.getContent();
const body = content.body; const body = content.body;
@@ -118,14 +131,20 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal variant="Surface" size="500"> <Modal
variant="Surface"
size="500"
role="dialog"
aria-modal="true"
aria-labelledby="edit-history-title"
>
<Header <Header
variant="Surface" variant="Surface"
size="500" size="500"
style={{ padding: '0 var(--mx-spacing-s200) 0 var(--mx-spacing-s400)' }} style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
> >
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4" truncate> <Text as="h2" id="edit-history-title" size="H4" truncate>
Edit History Edit History
</Text> </Text>
</Box> </Box>
@@ -139,15 +158,15 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
direction="Column" direction="Column"
gap="200" gap="200"
style={{ style={{
padding: 'var(--mx-spacing-s400)', padding: config.space.S400,
paddingBottom: 'var(--mx-spacing-s700)', paddingBottom: config.space.S700,
}} }}
> >
{historyState.status === AsyncStatus.Loading && ( {historyState.status === AsyncStatus.Loading && (
<Box <Box
justifyContent="Center" justifyContent="Center"
alignItems="Center" alignItems="Center"
style={{ padding: 'var(--mx-spacing-s400)' }} style={{ padding: config.space.S400 }}
> >
<Spinner size="200" /> <Spinner size="200" />
</Box> </Box>
@@ -159,7 +178,6 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
)} )}
{historyState.status === AsyncStatus.Success && ( {historyState.status === AsyncStatus.Success && (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
{/* Original message always shown first */}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Text size="L400">Original</Text> <Text size="L400">Original</Text>
@@ -172,11 +190,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
</Text> </Text>
</Box> </Box>
{historyState.data.map((editEvt, index) => ( {historyState.data.events.map((editEvt, index) => (
<Box key={editEvt.getId() ?? index} direction="Column" gap="100"> <Box key={editEvt.getId() ?? index} direction="Column" gap="100">
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Text size="L400"> <Text size="L400">
{index === historyState.data.length - 1 {index === historyState.data.events.length - 1
? `Edit ${index + 1} (current)` ? `Edit ${index + 1} (current)`
: `Edit ${index + 1}`} : `Edit ${index + 1}`}
</Text> </Text>
@@ -193,11 +211,19 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
</Box> </Box>
))} ))}
{historyState.data.length === 0 && ( {historyState.data.events.length === 0 && (
<Text size="T300" priority="300"> <Text size="T300" priority="300">
No edit history found. No edit history found.
</Text> </Text>
)} )}
{historyState.data.hasMore && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Text size="T200" priority="300">
Showing the 50 most recent edits
</Text>
</Box>
)}
</Box> </Box>
)} )}
</Box> </Box>
+75 -45
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds'; import { Box, Text, IconButton, Icon, Icons, Scroll, Button, Spinner, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@@ -8,7 +8,6 @@ import LotusLogo from '../../../../../public/res/Lotus.png';
import pkg from '../../../../../package.json'; import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { lotusTerminalBodyClass } from '../../../../lotus-terminal.css';
type MSC1929Contact = { type MSC1929Contact = {
matrix_id?: string; matrix_id?: string;
@@ -21,28 +20,50 @@ type MSC1929Support = {
support_page?: string; support_page?: string;
}; };
function useServerSupport(): MSC1929Support | null { function isMSC1929Support(data: unknown): data is MSC1929Support {
if (typeof data !== 'object' || data === null) return false;
const d = data as Record<string, unknown>;
if ('contacts' in d && !Array.isArray(d.contacts)) return false;
if ('support_page' in d && typeof d.support_page !== 'string') return false;
return true;
}
function formatRole(role?: string): string {
if (!role) return 'Contact';
if (role === 'm.role.admin') return 'Admin';
if (role === 'm.role.security') return 'Security';
return role.startsWith('m.role.') ? role.slice(7) : role;
}
function useServerSupport(): { support: MSC1929Support | null; loading: boolean } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [support, setSupport] = useState<MSC1929Support | null>(null); const [support, setSupport] = useState<MSC1929Support | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl; const controller = new AbortController();
fetch(`${baseUrl}/.well-known/matrix/support`) const baseUrl = mx.getHomeserverUrl();
setLoading(true);
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
.then((res) => { .then((res) => {
if (!res.ok) return null; if (!res.ok) return null;
return res.json() as Promise<MSC1929Support>; return res.json();
}) })
.then((data) => { .then((data) => {
if (data && (data.contacts?.length || data.support_page)) { if (isMSC1929Support(data) && (data.contacts?.length || data.support_page)) {
setSupport(data); setSupport(data);
} }
}) })
.catch(() => { .catch((e) => {
// Graceful degradation — server may not have this configured if (e.name !== 'AbortError') {
}); // Graceful degradation — server may not have this configured
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [mx]); }, [mx]);
return support; return { support, loading };
} }
type AboutProps = { type AboutProps = {
@@ -50,7 +71,7 @@ type AboutProps = {
}; };
export function About({ requestClose }: AboutProps) { export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const serverSupport = useServerSupport(); const { support: serverSupport, loading: supportLoading } = useServerSupport();
return ( return (
<Page> <Page>
@@ -145,7 +166,7 @@ export function About({ requestClose }: AboutProps) {
/> />
</SequenceCard> </SequenceCard>
</Box> </Box>
{serverSupport && ( {(serverSupport || supportLoading) && (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Homeserver Support</Text> <Text size="L400">Homeserver Support</Text>
<SequenceCard <SequenceCard
@@ -155,50 +176,59 @@ export function About({ requestClose }: AboutProps) {
gap="400" gap="400"
> >
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}> <Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
{serverSupport.contacts && serverSupport.contacts.length > 0 && ( {supportLoading && !serverSupport && (
<Box alignItems="Center" gap="200">
<Spinner size="100" variant="Secondary" />
<Text size="T300" priority="300">Loading support info</Text>
</Box>
)}
{serverSupport?.contacts && serverSupport.contacts.length > 0 && (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
{serverSupport.contacts.map((contact, i) => ( {serverSupport.contacts.map((contact, i) => (
<Box key={i} alignItems="Center" gap="200"> <Box key={i} direction="Column" gap="100">
<Text size="T300" priority="300"> <Text size="T300" priority="300">
{contact.role === 'm.role.admin' {formatRole(contact.role)}:
? 'Admin'
: contact.role === 'm.role.security'
? 'Security'
: 'Contact'}
:
</Text>
<Text
size="T300"
style={{
color: document.body.classList.contains(lotusTerminalBodyClass)
? 'var(--lt-accent-cyan)'
: undefined,
}}
>
{contact.matrix_id ?? contact.email_address ?? ''}
</Text> </Text>
<Box direction="Column" gap="100" style={{ paddingLeft: config.space.S200 }}>
{contact.matrix_id && (
<Text
as="a"
href={`https://matrix.to/#/${encodeURIComponent(contact.matrix_id)}`}
target="_blank"
rel="noreferrer noopener"
size="T300"
>
{contact.matrix_id}
</Text>
)}
{contact.email_address && (
<Text
as="a"
href={`mailto:${contact.email_address}`}
size="T300"
>
{contact.email_address}
</Text>
)}
</Box>
</Box> </Box>
))} ))}
</Box> </Box>
)} )}
{serverSupport.support_page && ( {serverSupport?.support_page && (
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Text size="T300" priority="300"> <Text size="T300" priority="300">
Support Page: Support Page:
</Text> </Text>
<Text size="T300"> <Text
<a as="a"
href={serverSupport.support_page} href={serverSupport.support_page}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
style={{ aria-label="Open support page"
color: document.body.classList.contains(lotusTerminalBodyClass) size="T300"
? 'var(--lt-accent-cyan)' >
: undefined, {serverSupport.support_page}
}}
>
{serverSupport.support_page}
</a>
</Text> </Text>
</Box> </Box>
)} )}
+26 -7
View File
@@ -14,6 +14,7 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
color,
config, config,
Header, Header,
Icon, Icon,
@@ -408,8 +409,8 @@ function Appearance() {
title="Replay boot sequence" title="Replay boot sequence"
style={{ style={{
background: 'transparent', background: 'transparent',
border: '1px solid rgba(255,107,0,0.35)', border: '1px solid var(--accent-orange-border)',
color: '#FF6B00', color: 'var(--accent-orange)',
fontSize: '0.65rem', fontSize: '0.65rem',
padding: '0.2rem 0.6rem', padding: '0.2rem 0.6rem',
cursor: 'pointer', cursor: 'pointer',
@@ -964,14 +965,14 @@ function ChatBgGrid() {
cursor: 'pointer', cursor: 'pointer',
border: border:
chatBackground === opt.value chatBackground === opt.value
? '2px solid #980000' ? `2px solid ${color.Critical.Main}`
: '2px solid rgba(128,128,128,0.25)', : '2px solid rgba(128,128,128,0.25)',
padding: 0, padding: 0,
overflow: 'hidden', overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark), ...getChatBg(opt.value as ChatBackground, isDark),
}} }}
/> />
<Text size="T200" style={chatBackground === opt.value ? { color: '#980000' } : undefined}> <Text size="T200" style={chatBackground === opt.value ? { color: color.Critical.Main } : undefined}>
{opt.label} {opt.label}
</Text> </Text>
</Box> </Box>
@@ -1195,14 +1196,32 @@ function Messages() {
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Url Preview" title="URL Preview"
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />} after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Url Preview in Encrypted Room" title="URL Preview in Encrypted Rooms"
description="URL previews in encrypted rooms are fetched by your homeserver, which sees the URL but not the message content." description={
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
Your homeserver fetches link previews on your behalf. It cannot decrypt your
messages, but will see every URL you preview in encrypted rooms, potentially
revealing conversation topics.
</Text>
<Chip
variant="Warning"
fill="Soft"
radii="300"
size="400"
style={{ alignSelf: 'flex-start' }}
>
<Text size="T200">Privacy risk enabled by default</Text>
</Chip>
</Box>
}
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />} after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/> />
</SequenceCard> </SequenceCard>
+1 -1
View File
@@ -39,7 +39,7 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> }; export type LocalRoomNamesContent = { rooms: Record<string, string> };
function getLocalRoomNamesContent(mx: ReturnType<typeof useMatrixClient>): LocalRoomNamesContent { export function getLocalRoomNamesContent(mx: ReturnType<typeof useMatrixClient>): LocalRoomNamesContent {
// Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents // Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent(); const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
+2 -2
View File
@@ -158,8 +158,8 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
}, },
allowedStyles: { allowedStyles: {
'*': { '*': {
color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/], color: [/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/],
'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/], 'background-color': [/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/],
}, },
}, },
transformTags: { transformTags: {