feat: P1 features — quick switcher, media gallery, DM previews, knock-to-join, syntax highlighting

P1-1: Quick room switcher (Ctrl+K/Cmd+K) — QuickSwitcher.tsx + ClientNonUIFeatures hotkey
P1-2: Media gallery drawer (images/videos/files) — MediaGallery.tsx + RoomViewHeader toggle
P1-4: DM last message preview + relative timestamp in RoomNavItem when direct=true
P1-7: Code syntax highlighting — TDS tokenizer (syntaxHighlight.ts), custom CSS theme
       (.prism-tds-dark/.prism-tds-light), applied in react-custom-html-parser.tsx
P1-11: Knock-to-join — "Request to Join" in RoomIntro + Pending Requests in MembersDrawer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:45:57 -04:00
parent afe957015b
commit d43044ccbf
11 changed files with 1468 additions and 271 deletions
+90 -21
View File
@@ -27,6 +27,9 @@ import {
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -67,8 +70,31 @@ import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
const PREVIEW_MAX_CHARS = 48;
function formatDmTimestamp(ts: number): string {
const d = dayjs(ts);
const now = dayjs();
const diffMinutes = now.diff(d, 'minute');
if (diffMinutes < 60) {
return `${diffMinutes < 1 ? 0 : diffMinutes}m`;
}
const diffHours = now.diff(d, 'hour');
if (diffHours < 24) {
return `${diffHours}h`;
}
if (d.isYesterday()) {
return 'Yesterday';
}
return d.format('D MMM');
}
type RenameRoomDialogProps = {
room: Room;
@@ -419,6 +445,28 @@ function RoomNavItem_({
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
const latestEvent = useRoomLatestRenderedEvent(room);
const dmPreview = (() => {
if (!direct || !latestEvent) return null;
const type = latestEvent.getType();
const ts = latestEvent.getTs();
if (!ts) return null;
// Skip pure membership events
if (type === StateEvent.RoomMember) return null;
let body: string;
if (latestEvent.isEncrypted()) {
body = 'Encrypted message';
} else if (type === MessageEvent.Sticker) {
body = 'Sticker';
} else {
const rawBody: unknown = latestEvent.getContent()?.body;
body = typeof rawBody === 'string' ? rawBody.replace(/\s+/g, ' ').trim() : '';
}
if (!body) return null;
const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}` : body;
return { preview, time: formatDmTimestamp(ts) };
})();
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenuAnchor({
@@ -510,26 +558,47 @@ function RoomNavItem_({
/>
)}
</Avatar>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
<Icon
size="50"
src={Icons.Pencil}
aria-label="Custom local name"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{isFavorite && (
<Icon
size="50"
src={Icons.Star}
filled
aria-label="Favorited"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
<Icon
size="50"
src={Icons.Pencil}
aria-label="Custom local name"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{isFavorite && (
<Icon
size="50"
src={Icons.Star}
filled
aria-label="Favorited"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
</Box>
{dmPreview && (
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text
as="span"
size="T200"
truncate
style={{ opacity: config.opacity.P300, flexGrow: 1 }}
>
{dmPreview.preview}
</Text>
<Text
as="span"
size="T200"
style={{ opacity: config.opacity.P300, flexShrink: 0, whiteSpace: 'nowrap' }}
>
{dmPreview.time}
</Text>
</Box>
)}
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (