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:
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user