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:
@@ -0,0 +1,206 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Avatar, Box, Icon, Icons, Input, Text, config } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
|
||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||
import { nameInitials } from '../utils/common';
|
||||
|
||||
const MAX_RESULTS = 10;
|
||||
|
||||
function RoomFallback({ room }: { room: Room }) {
|
||||
return (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type QuickSwitcherProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function QuickSwitcher({ onClose }: QuickSwitcherProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const allRoomIds = useAtomValue(allRoomsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const filteredRooms = query.trim()
|
||||
? allRoomIds
|
||||
.map((id) => ({ id, room: mx.getRoom(id) }))
|
||||
.filter(({ room }) => room && room.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.slice(0, MAX_RESULTS)
|
||||
: allRoomIds
|
||||
.map((id) => ({ id, room: mx.getRoom(id) }))
|
||||
.filter(({ room }) => !!room)
|
||||
.slice(0, MAX_RESULTS);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setQuery(evt.currentTarget.value);
|
||||
setSelectedIdx(0);
|
||||
};
|
||||
|
||||
const navigateToRoom = useCallback(
|
||||
(roomId: string) => {
|
||||
navigateRoom(roomId);
|
||||
onClose();
|
||||
},
|
||||
[navigateRoom, onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
||||
(evt) => {
|
||||
if (evt.key === 'ArrowDown') {
|
||||
evt.preventDefault();
|
||||
setSelectedIdx((i) => Math.min(i + 1, filteredRooms.length - 1));
|
||||
} else if (evt.key === 'ArrowUp') {
|
||||
evt.preventDefault();
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (evt.key === 'Enter') {
|
||||
const item = filteredRooms[selectedIdx];
|
||||
if (item) navigateToRoom(item.id);
|
||||
} else if (evt.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[filteredRooms, selectedIdx, navigateToRoom, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 9899,
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Quick Room Switcher"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(560px, 90vw)',
|
||||
zIndex: 9900,
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<Box style={{ padding: config.space.S300 }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
placeholder="Search rooms…"
|
||||
size="500"
|
||||
variant="Background"
|
||||
outlined
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Results list */}
|
||||
{filteredRooms.length > 0 && (
|
||||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||
{filteredRooms.map(({ id, room }, idx) => {
|
||||
if (!room) return null;
|
||||
const dm = mDirects.has(id);
|
||||
const avatarUrl = dm
|
||||
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 32, useAuthentication);
|
||||
const isSelected = idx === selectedIdx;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => navigateToRoom(id)}
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
background: isSelected
|
||||
? 'var(--bg-surface-hover, rgba(255,255,255,0.08))'
|
||||
: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Avatar size="200" radii={dm ? '400' : '300'}>
|
||||
{dm || room.isSpaceRoom() ? (
|
||||
<RoomAvatar
|
||||
roomId={id}
|
||||
src={avatarUrl}
|
||||
alt={room.name}
|
||||
renderFallback={() => <RoomFallback room={room} />}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
size="200"
|
||||
joinRule={room.getJoinRule()}
|
||||
roomType={room.getType()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Text truncate size="T400">
|
||||
{room.name}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredRooms.length === 0 && (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S500 }}>
|
||||
<Text size="T300" align="Center">
|
||||
{query.trim() ? `No rooms matching "${query}"` : 'No rooms'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
@@ -42,6 +43,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
const [viewTopic, setViewTopic] = useState(false);
|
||||
const [knocked, setKnocked] = useState(false);
|
||||
const [knockError, setKnockError] = useState<string | undefined>();
|
||||
|
||||
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
@@ -168,6 +171,36 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<Text size="B300">Join Old Room</Text>
|
||||
</Button>
|
||||
))}
|
||||
{room.getJoinRule() === JoinRule.Knock &&
|
||||
room.getMyMembership() !== Membership.Join &&
|
||||
(knocked ? (
|
||||
<Text size="T300" priority="300">
|
||||
Request sent — waiting for room admin approval
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKnockError(undefined);
|
||||
mx.knockRoom(room.roomId)
|
||||
.then(() => setKnocked(true))
|
||||
.catch((err: Error) =>
|
||||
setKnockError(err.message ?? 'Failed to send request'),
|
||||
);
|
||||
}}
|
||||
variant="Primary"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Request to Join</Text>
|
||||
</Button>
|
||||
{knockError && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
{knockError}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user