From 16dddcb9f053cff7b8f5d02581336cda8738e1fc Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 1 Jun 2026 21:30:27 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20comprehensive=20P0=20quality=20pass=20?= =?UTF-8?q?=E2=80=94=20audit=20findings=20resolved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
with folds 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 --- src/app/components/room-intro/RoomIntro.tsx | 52 +++++++- src/app/features/call-status/CallRoomName.tsx | 4 +- src/app/features/room-nav/RoomNavItem.tsx | 57 +++----- src/app/features/room/ReportRoomModal.tsx | 63 ++++++--- src/app/features/room/RoomInput.tsx | 12 +- src/app/features/room/RoomViewHeader.tsx | 107 +++++++++------ .../room/message/EditHistoryModal.tsx | 122 +++++++++++------- src/app/features/settings/about/About.tsx | 120 ++++++++++------- src/app/features/settings/general/General.tsx | 33 ++++- src/app/hooks/useRoomMeta.ts | 2 +- src/app/utils/sanitize.ts | 4 +- 11 files changed, 363 insertions(+), 213 deletions(-) diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index f473c2203..80ec0297a 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,5 +1,6 @@ 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 { useAtomValue } from 'jotai'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; @@ -11,12 +12,14 @@ import { timeDayMonthYear, timeHourMinute } from '../../utils/time'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { RoomAvatar } from '../room-avatar'; 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 { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { RoomTopicViewer } from '../room-topic-viewer'; +import { stopPropagation } from '../../utils/keyboard'; export type RoomIntroProps = { room: Room; @@ -28,10 +31,11 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const { navigateRoom } = useRoomNavigate(); const mDirects = useAtomValue(mDirectAtom); const [invitePrompt, setInvitePrompt] = useState(false); + const [viewTopic, setViewTopic] = useState(false); const createEvent = getStateEvent(room, StateEvent.RoomCreate); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); - const name = useRoomName(room); + const name = useLocalRoomName(room); const topic = useRoomTopic(room); const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; @@ -65,9 +69,45 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {name} - - {topic?.topic ?? 'This is the beginning of conversation.'} - + {topic ? ( + <> + {viewTopic && ( + }> + + setViewTopic(false), + escapeDeactivates: stopPropagation, + }} + > + setViewTopic(false)} + /> + + + + )} + setViewTopic(true)} + size="T400" + priority="400" + style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }} + > + {topic.topic} + + + ) : ( + + This is the beginning of conversation. + + )} {creatorName && ts && ( {'Created by '} diff --git a/src/app/features/call-status/CallRoomName.tsx b/src/app/features/call-status/CallRoomName.tsx index 8b65528a6..9b4192367 100644 --- a/src/app/features/call-status/CallRoomName.tsx +++ b/src/app/features/call-status/CallRoomName.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Room } from 'matrix-js-sdk'; import { Chip, Icon, Icons, Text } from 'folds'; import { useAtomValue } from 'jotai'; -import { useRoomName } from '../../hooks/useRoomMeta'; +import { useLocalRoomName } from '../../hooks/useRoomMeta'; import { RoomIcon } from '../../components/room-avatar'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { getAllParents, guessPerfectParent } from '../../utils/room'; @@ -18,7 +18,7 @@ type CallRoomNameProps = { }; export function CallRoomName({ room }: CallRoomNameProps) { const mx = useMatrixClient(); - const name = useRoomName(room); + const name = useLocalRoomName(room); const roomToParents = useAtomValue(roomToParentsAtom); const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); const mDirects = useAtomValue(mDirectAtom); diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 6a90995d7..72be21d3e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -61,7 +61,7 @@ import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPe import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { LOCAL_ROOM_NAMES_KEY, - LocalRoomNamesContent, + getLocalRoomNamesContent, useHasLocalRoomName, useLocalRoomName, } from '../../hooks/useRoomMeta'; @@ -82,29 +82,15 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { const mx = useMatrixClient(); const inputRef = useRef(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 content = getExistingContent(); + const content = getLocalRoomNamesContent(mx); return content.rooms[room.roomId] ?? ''; - }, [getExistingContent, room.roomId]); + }, [mx, room.roomId]); const handleSave = useCallback(() => { const newName = inputRef.current?.value.trim() ?? ''; - const existing = getExistingContent(); + if (newName.length > 255) return; + const existing = getLocalRoomNamesContent(mx); if (newName === '') { const { [room.roomId]: _removed, ...rest } = existing.rooms; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -116,15 +102,15 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { }); } onClose(); - }, [mx, room.roomId, getExistingContent, onClose]); + }, [mx, room.roomId, onClose]); const handleClear = useCallback(() => { - const existing = getExistingContent(); + const existing = getLocalRoomNamesContent(mx); const { [room.roomId]: _removed, ...rest } = existing.rooms; // eslint-disable-next-line @typescript-eslint/no-explicit-any (mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest }); onClose(); - }, [mx, room.roomId, getExistingContent, onClose]); + }, [mx, room.roomId, onClose]); const handleKeyDown = (evt: React.KeyboardEvent) => { if (evt.key === 'Enter') handleSave(); @@ -169,6 +155,7 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { placeholder={room.name} variant="Secondary" radii="300" + maxLength={255} onKeyDown={handleKeyDown} autoFocus /> @@ -210,10 +197,11 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { type RoomNavItemMenuProps = { room: Room; requestClose: () => void; + onRenameClick: () => void; notificationMode?: RoomNotificationMode; }; const RoomNavItemMenu = forwardRef( - ({ room, requestClose, notificationMode }, ref) => { + ({ room, requestClose, onRenameClick, notificationMode }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); @@ -226,7 +214,6 @@ const RoomNavItemMenu = forwardRef( const space = useSpaceOptionally(); const [invitePrompt, setInvitePrompt] = useState(false); - const [renameDialog, setRenameDialog] = useState(false); const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); @@ -260,15 +247,6 @@ const RoomNavItemMenu = forwardRef( }} /> )} - {renameDialog && ( - { - setRenameDialog(false); - requestClose(); - }} - /> - )} ( setRenameDialog(true)} + onClick={() => { + requestClose(); + onRenameClick(); + }} size="300" after={} radii="300" - aria-pressed={renameDialog} > Rename for me… @@ -425,6 +405,7 @@ function RoomNavItem_({ const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); + const [renameDialog, setRenameDialog] = useState(false); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId(), @@ -533,7 +514,7 @@ function RoomNavItem_({ size="50" src={Icons.Pencil} aria-label="Custom local name" - style={{ opacity: 0.6, flexShrink: 0 }} + style={{ opacity: config.opacity.P300, flexShrink: 0 }} /> )} @@ -592,6 +573,7 @@ function RoomNavItem_({ setMenuAnchor(undefined)} + onRenameClick={() => setRenameDialog(true)} notificationMode={notificationMode} /> @@ -612,6 +594,9 @@ function RoomNavItem_({ )} + {renameDialog && ( + setRenameDialog(false)} /> + )} ); } diff --git a/src/app/features/room/ReportRoomModal.tsx b/src/app/features/room/ReportRoomModal.tsx index 6e213dde6..32c122059 100644 --- a/src/app/features/room/ReportRoomModal.tsx +++ b/src/app/features/room/ReportRoomModal.tsx @@ -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 { Box, @@ -16,7 +16,6 @@ import { color, Spinner, } from 'folds'; -import { Method } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { stopPropagation } from '../../utils/keyboard'; @@ -42,17 +41,20 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) { const [reportState, submitReport] = useAsyncCallback( useCallback( async (reason: string) => { - await mx.http.authedRequest( - Method.Post, - `/rooms/${encodeURIComponent(roomId)}/report`, - undefined, - { reason }, - ); + await mx.reportRoom(roomId, reason); }, [mx, roomId], ), ); + useEffect(() => { + if (reportState.status === AsyncStatus.Success) { + const timer = setTimeout(onClose, 1500); + return () => clearTimeout(timer); + } + return undefined; + }, [reportState.status, onClose]); + const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); 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 reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null; const reasonText = reasonInput?.value.trim() ?? ''; - const fullReason = reasonText - ? `[${CATEGORY_LABELS[category]}] ${reasonText}` - : `[${CATEGORY_LABELS[category]}]`; + const fullReason = `[${CATEGORY_LABELS[category]}] ${reasonText}`; 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 ( }> @@ -80,12 +90,15 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) { > - Report Room + Report Room @@ -113,9 +126,11 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) { - Category + Category ) => setCategory(e.target.value as ReportCategory) @@ -123,9 +138,9 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) { style={{ padding: `${config.space.S200} ${config.space.S300}`, borderRadius: config.radii.R300, - border: '1px solid var(--mx-border)', - background: 'var(--mx-bg-surface)', - color: 'var(--mx-c-surface-on)', + border: `1px solid ${color.Surface.ContainerLine}`, + background: color.Surface.Container, + color: color.Surface.OnContainer, fontSize: 'inherit', fontFamily: 'inherit', width: '100%', @@ -140,11 +155,17 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) { - Reason - + Reason + {reportState.status === AsyncStatus.Error && ( - Failed to submit report. Please try again. + {errorMsg} )} {reportState.status === AsyncStatus.Success && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index a03f50ff9..dee901aa0 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -605,11 +605,17 @@ export const RoomInput = forwardRef( if (room.getType() === 'm.server_notice') { return ( -
+ } + direction="Column" + alignItems="Center" + justifyContent="Center" + style={{ padding: config.space.S300 }} + > - This is a server notice room — you cannot send messages here. + This room contains system messages from your homeserver. Replies are not permitted. -
+
); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 9ef349b6c..4daeeed5e 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -48,7 +48,8 @@ import { markAsRead } from '../../utils/notifications'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { copyToClipboard } from '../../utils/dom'; 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 { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; @@ -89,6 +90,9 @@ const RoomMenu = forwardRef(({ room, requestClose const permissions = useRoomPermissions(creators, powerLevels); 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 notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); @@ -175,20 +179,22 @@ const RoomMenu = forwardRef(({ room, requestClose
- } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - + {!isServerNotice && ( + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + )} (({ room, requestClose Copy Link - } - radii="300" - > - - Room Settings - - + {!isServerNotice && ( + } + radii="300" + > + + Room Settings + + + )} {(promptJump, setPromptJump) => ( <> @@ -239,19 +247,21 @@ const RoomMenu = forwardRef(({ room, requestClose - setReportRoomOpen(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={reportRoomOpen} - > - - Report Room - - + {reportRoomSupported && !isServerNotice && !isCreator && ( + setReportRoomOpen(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={reportRoomOpen} + > + + Report Room + + + )} {(promptLeave, setPromptLeave) => ( <> @@ -436,7 +446,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptedRoom = !!encryptionEvent; const avatarMxc = useRoomAvatar(room, direct); - const name = useRoomName(room); + const name = useLocalRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) @@ -508,9 +518,21 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { {name} {room.getType() === 'm.server_notice' && ( - - Server Notice - + + System messages from your homeserver administrator. + + } + > + {(triggerRef) => ( + + Server Notice + + )} + )} {topic && ( @@ -522,6 +544,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { setViewTopic(false), escapeDeactivates: stopPropagation, diff --git a/src/app/features/room/message/EditHistoryModal.tsx b/src/app/features/room/message/EditHistoryModal.tsx index ee497ad60..aea3a3e5d 100644 --- a/src/app/features/room/message/EditHistoryModal.tsx +++ b/src/app/features/room/message/EditHistoryModal.tsx @@ -13,6 +13,7 @@ import { Scroll, Spinner, Text, + config, } from 'folds'; import { MatrixEvent, Method, Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -22,17 +23,48 @@ import { timeDayMonYear, timeHourMinute } from '../../../utils/time'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +type RawEditEvent = { + type: string; + content: Record; + origin_server_ts: number; + event_id: string; +}; + type EditHistoryResponse = { chunk: Array>; next_batch?: string; }; +type EditHistoryData = { + events: MatrixEvent[]; + hasMore: boolean; +}; + type EditHistoryModalProps = { room: Room; mEvent: MatrixEvent; onClose: () => void; }; +function isRawEditEvent(raw: unknown): raw is RawEditEvent { + if (typeof raw !== 'object' || raw === null) return false; + const r = raw as Record; + 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 | 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) { const mx = useMatrixClient(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); @@ -41,43 +73,32 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp const eventId = mEvent.getId(); const roomId = room.roomId; - const [historyState, fetchHistory] = useAsyncCallback( + const [historyState, fetchHistory] = useAsyncCallback( 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(Method.Get, path, { limit: '50', }); const rawEvents = res.chunk ?? []; - // Sort oldest first const events = rawEvents - .map( - (raw) => - // Build a lightweight representation for display; we just need content + ts - raw as { - type: string; - content: Record; - origin_server_ts: number; - event_id: string; - }, - ) - .sort((a, b) => a.origin_server_ts - b.origin_server_ts); - - // 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() ?? '', + .filter(isRawEditEvent) + .sort((a, b) => a.origin_server_ts - b.origin_server_ts) + .map((raw) => { + const existing = room.findEventById(raw.event_id); + if (existing) return existing; + return 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]), ); @@ -91,14 +112,6 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp return `${date} at ${time}`; }; - const getVersionBody = (evt: MatrixEvent): string => { - const content = evt.getContent(); - const newContent = content['m.new_content'] as Record | undefined; - const body = newContent?.body ?? content.body; - return typeof body === 'string' ? body : '(no text)'; - }; - - // The original message body (before any edits) const originalBody = (() => { const content = mEvent.getContent(); const body = content.body; @@ -118,14 +131,20 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp escapeDeactivates: stopPropagation, }} > - +
- + Edit History @@ -139,15 +158,15 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp direction="Column" gap="200" style={{ - padding: 'var(--mx-spacing-s400)', - paddingBottom: 'var(--mx-spacing-s700)', + padding: config.space.S400, + paddingBottom: config.space.S700, }} > {historyState.status === AsyncStatus.Loading && ( @@ -159,7 +178,6 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp )} {historyState.status === AsyncStatus.Success && ( - {/* Original message always shown first */} Original @@ -172,11 +190,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp - {historyState.data.map((editEvt, index) => ( + {historyState.data.events.map((editEvt, index) => ( - {index === historyState.data.length - 1 + {index === historyState.data.events.length - 1 ? `Edit ${index + 1} (current)` : `Edit ${index + 1}`} @@ -193,11 +211,19 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp ))} - {historyState.data.length === 0 && ( + {historyState.data.events.length === 0 && ( No edit history found. )} + + {historyState.data.hasMore && ( + + + Showing the 50 most recent edits + + + )} )} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 1c6db302a..69b68f596 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -1,5 +1,5 @@ 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 { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -8,7 +8,6 @@ import LotusLogo from '../../../../../public/res/Lotus.png'; import pkg from '../../../../../package.json'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { lotusTerminalBodyClass } from '../../../../lotus-terminal.css'; type MSC1929Contact = { matrix_id?: string; @@ -21,28 +20,50 @@ type MSC1929Support = { 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; + 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 [support, setSupport] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl; - fetch(`${baseUrl}/.well-known/matrix/support`) + const controller = new AbortController(); + const baseUrl = mx.getHomeserverUrl(); + setLoading(true); + fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal }) .then((res) => { if (!res.ok) return null; - return res.json() as Promise; + return res.json(); }) .then((data) => { - if (data && (data.contacts?.length || data.support_page)) { + if (isMSC1929Support(data) && (data.contacts?.length || data.support_page)) { setSupport(data); } }) - .catch(() => { - // Graceful degradation — server may not have this configured - }); + .catch((e) => { + if (e.name !== 'AbortError') { + // Graceful degradation — server may not have this configured + } + }) + .finally(() => setLoading(false)); + return () => controller.abort(); }, [mx]); - return support; + return { support, loading }; } type AboutProps = { @@ -50,7 +71,7 @@ type AboutProps = { }; export function About({ requestClose }: AboutProps) { const mx = useMatrixClient(); - const serverSupport = useServerSupport(); + const { support: serverSupport, loading: supportLoading } = useServerSupport(); return ( @@ -145,7 +166,7 @@ export function About({ requestClose }: AboutProps) { /> - {serverSupport && ( + {(serverSupport || supportLoading) && ( Homeserver Support - {serverSupport.contacts && serverSupport.contacts.length > 0 && ( + {supportLoading && !serverSupport && ( + + + Loading support info… + + )} + {serverSupport?.contacts && serverSupport.contacts.length > 0 && ( {serverSupport.contacts.map((contact, i) => ( - + - {contact.role === 'm.role.admin' - ? 'Admin' - : contact.role === 'm.role.security' - ? 'Security' - : 'Contact'} - : - - - {contact.matrix_id ?? contact.email_address ?? ''} + {formatRole(contact.role)}: + + {contact.matrix_id && ( + + {contact.matrix_id} + + )} + {contact.email_address && ( + + {contact.email_address} + + )} + ))} )} - {serverSupport.support_page && ( + {serverSupport?.support_page && ( Support Page: - - - {serverSupport.support_page} - + + {serverSupport.support_page} )} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5b5c07fea..437ae8bf6 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -14,6 +14,7 @@ import { Box, Button, Chip, + color, config, Header, Icon, @@ -408,8 +409,8 @@ function Appearance() { title="Replay boot sequence" style={{ background: 'transparent', - border: '1px solid rgba(255,107,0,0.35)', - color: '#FF6B00', + border: '1px solid var(--accent-orange-border)', + color: 'var(--accent-orange)', fontSize: '0.65rem', padding: '0.2rem 0.6rem', cursor: 'pointer', @@ -964,14 +965,14 @@ function ChatBgGrid() { cursor: 'pointer', border: chatBackground === opt.value - ? '2px solid #980000' + ? `2px solid ${color.Critical.Main}` : '2px solid rgba(128,128,128,0.25)', padding: 0, overflow: 'hidden', ...getChatBg(opt.value as ChatBackground, isDark), }} /> - + {opt.label} @@ -1195,14 +1196,32 @@ function Messages() { } /> + + 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. + + + Privacy risk — enabled by default + + + } after={} /> diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index 8be2ba5b9..4f062d5c3 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -39,7 +39,7 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names'; export type LocalRoomNamesContent = { rooms: Record }; -function getLocalRoomNamesContent(mx: ReturnType): LocalRoomNamesContent { +export function getLocalRoomNamesContent(mx: ReturnType): LocalRoomNamesContent { // Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents // eslint-disable-next-line @typescript-eslint/no-explicit-any const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent(); diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 5887d729f..82de8efab 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -158,8 +158,8 @@ export const sanitizeCustomHtml = (customHtml: string): string => }, allowedStyles: { '*': { - color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/], - 'background-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,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/], }, }, transformTags: {