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,334 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Direction, EventType, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file';
|
||||
|
||||
type MediaGalleryProps = {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TAB_LABELS: Record<GalleryTab, string> = {
|
||||
image: 'Images',
|
||||
video: 'Videos',
|
||||
file: 'Files',
|
||||
};
|
||||
|
||||
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
image: MsgType.Image,
|
||||
video: MsgType.Video,
|
||||
file: MsgType.File,
|
||||
};
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Soft' : 'None'}
|
||||
radii="300"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [tab, setTab] = useState<GalleryTab>('image');
|
||||
const [events, setEvents] = useState<MatrixEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paginationToken, setPaginationToken] = useState<string | null>(null);
|
||||
|
||||
const msgtype = TAB_MSGTYPES[tab];
|
||||
|
||||
const loadMedia = useCallback(
|
||||
async (fromToken: string | null, append: boolean) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await mx.createMessagesRequest(
|
||||
room.roomId,
|
||||
fromToken,
|
||||
100,
|
||||
Direction.Backward,
|
||||
undefined,
|
||||
);
|
||||
const { end, chunk } = response;
|
||||
const filtered = chunk
|
||||
.filter(
|
||||
(ev) =>
|
||||
ev.type === EventType.RoomMessage &&
|
||||
ev.content?.msgtype === msgtype &&
|
||||
!ev.unsigned?.redacted_because,
|
||||
)
|
||||
.map((ev) => new MatrixEvent(ev));
|
||||
|
||||
setEvents((prev) => (append ? [...prev, ...filtered] : filtered));
|
||||
setPaginationToken(end ?? null);
|
||||
} catch {
|
||||
// silently swallow fetch errors — gallery stays showing what it has
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, msgtype],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEvents([]);
|
||||
setPaginationToken(null);
|
||||
loadMedia(null, false).catch(() => undefined);
|
||||
}, [loadMedia]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (paginationToken) loadMedia(paginationToken, true).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '320px',
|
||||
zIndex: 500,
|
||||
background: 'var(--bg-surface)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
variant="Background"
|
||||
size="600"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Photo} />
|
||||
<Text size="H5" truncate>
|
||||
Media
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Close</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
aria-label="Close media gallery"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
}}
|
||||
>
|
||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||
{loading && events.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && events.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Image/Video grid */}
|
||||
{(tab === 'image' || tab === 'video') && events.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{events.map((mEvent) => {
|
||||
const content = mEvent.getContent();
|
||||
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
||||
if (!mxcUrl) return null;
|
||||
const thumbUrl =
|
||||
mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? '';
|
||||
const body: string = content.body ?? '';
|
||||
return (
|
||||
<a
|
||||
key={mEvent.getId()}
|
||||
href={mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: config.radii.R300,
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
title={body}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={body}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{tab === 'file' && events.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
{events.map((mEvent) => {
|
||||
const content = mEvent.getContent();
|
||||
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
||||
const body: string = content.body ?? 'Unnamed file';
|
||||
const downloadUrl = mxcUrl
|
||||
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
|
||||
: '#';
|
||||
return (
|
||||
<Box
|
||||
key={mEvent.getId()}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: 'var(--bg-surface)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.File} />
|
||||
<Box grow="Yes" style={{ overflow: 'hidden' }}>
|
||||
<Text size="T300" truncate title={body}>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant="Background"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={`Download ${body}`}
|
||||
onClick={() => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = downloadUrl;
|
||||
anchor.download = body;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noreferrer';
|
||||
anchor.click();
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.Download} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{paginationToken !== null && !loading && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
<Text size="B300">Load more</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading more spinner */}
|
||||
{loading && events.length > 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user