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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Header,
|
||||
Icon,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
import * as css from './MembersDrawer.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -51,7 +53,11 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
||||
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
||||
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import {
|
||||
readPowerLevel,
|
||||
useGetMemberPowerLevel,
|
||||
usePowerLevelsContext,
|
||||
} from '../../hooks/usePowerLevels';
|
||||
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
||||
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
||||
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
||||
@@ -225,6 +231,15 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
|
||||
const myUserId = mx.getUserId();
|
||||
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
||||
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
||||
const canApproveKnock = myPowerLevel >= invitePowerLevel;
|
||||
const knockMembers = useMemo(
|
||||
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
|
||||
[room, canApproveKnock],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
||||
[members, membershipFilter, memberSort, memberPowerSort],
|
||||
@@ -392,6 +407,78 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
|
||||
{knockMembers.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text
|
||||
style={{ padding: `${config.space.S100} ${config.space.S200}` }}
|
||||
size="L400"
|
||||
priority="300"
|
||||
>
|
||||
Pending Requests
|
||||
</Text>
|
||||
{knockMembers.map((knockMember) => {
|
||||
const knockName =
|
||||
getMemberDisplayName(room, knockMember.userId) ??
|
||||
getMxIdLocalPart(knockMember.userId) ??
|
||||
knockMember.userId;
|
||||
const knockAvatarMxc = knockMember.getMxcAvatarUrl();
|
||||
const knockAvatarUrl = knockAvatarMxc
|
||||
? mx.mxcUrlToHttp(
|
||||
knockAvatarMxc,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false,
|
||||
useAuthentication,
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<Box
|
||||
key={knockMember.userId}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={knockMember.userId}
|
||||
src={knockAvatarUrl ?? undefined}
|
||||
alt={knockName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" truncate>
|
||||
{knockName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Approve</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!fetchingMembers && !result && processMembers.length === 0 && (
|
||||
<Text style={{ padding: config.space.S300 }} align="Center">
|
||||
{`No "${membershipFilter.name}" Members`}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { MediaGallery } from './MediaGallery';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -431,6 +432,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
: undefined;
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const searchParams: _SearchPathSearchParams = {
|
||||
@@ -461,258 +463,284 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
balance={screenSize === ScreenSize.Mobile}
|
||||
>
|
||||
<Box grow="Yes" gap="300">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<IconButton fill="None" onClick={onBack} aria-label="Back">
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
<Box grow="Yes" alignItems="Center" gap="300">
|
||||
{screenSize !== ScreenSize.Mobile && (
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<>
|
||||
<PageHeader
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
balance={screenSize === ScreenSize.Mobile}
|
||||
>
|
||||
<Box grow="Yes" gap="300">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<IconButton fill="None" onClick={onBack} aria-label="Back">
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
<Box direction="Column">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{room.getType() === 'm.server_notice' && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
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>
|
||||
<Box grow="Yes" alignItems="Center" gap="300">
|
||||
{screenSize !== ScreenSize.Mobile && (
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
</TooltipProvider>
|
||||
/>
|
||||
</Avatar>
|
||||
)}
|
||||
<Box direction="Column">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{room.getType() === 'm.server_notice' && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
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>
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(viewTopic, setViewTopic) => (
|
||||
<>
|
||||
<Overlay open={viewTopic} 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)}
|
||||
className={css.HeaderTopic}
|
||||
size="T200"
|
||||
priority="300"
|
||||
truncate
|
||||
>
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
</Box>
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(viewTopic, setViewTopic) => (
|
||||
<>
|
||||
<Overlay open={viewTopic} 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)}
|
||||
className={css.HeaderTopic}
|
||||
size="T200"
|
||||
priority="300"
|
||||
truncate
|
||||
>
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</>
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{!encryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleSearchClick}
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{!room.isCallRoom() &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
hasCallPermission &&
|
||||
(direct ||
|
||||
(room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{galleryOpen ? 'Hide Gallery' : 'Media Gallery'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setGalleryOpen(!galleryOpen)}
|
||||
aria-label="Toggle media gallery"
|
||||
aria-pressed={galleryOpen}
|
||||
>
|
||||
<Icon size="400" src={Icons.Photo} filled={galleryOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{callView ? (
|
||||
<Text>Members</Text>
|
||||
) : (
|
||||
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label="Toggle member list"
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>More Options</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-label="More options"
|
||||
aria-expanded={!!menuAnchor}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{!encryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleSearchClick}
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{!room.isCallRoom() &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
hasCallPermission &&
|
||||
(direct ||
|
||||
(room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
{callView ? (
|
||||
<Text>Members</Text>
|
||||
) : (
|
||||
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label="Toggle member list"
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>More Options</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-label="More options"
|
||||
aria-expanded={!!menuAnchor}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
</PageHeader>
|
||||
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ButterTheme: Theme = {
|
||||
export const LotusTerminalTheme: Theme = {
|
||||
id: 'lotus-terminal-theme',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'],
|
||||
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-tds-dark'],
|
||||
};
|
||||
export const LotusTerminalLightTheme: Theme = {
|
||||
id: 'lotus-terminal-light-theme',
|
||||
@@ -55,7 +55,7 @@ export const LotusTerminalLightTheme: Theme = {
|
||||
'lotus-terminal-light-theme',
|
||||
lotusTerminalLightTheme,
|
||||
onLightFontWeight,
|
||||
'prism-light',
|
||||
'prism-tds-light',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
@@ -27,6 +27,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||
import { QuickSwitcher } from '../../components/QuickSwitcher';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
@@ -261,6 +262,26 @@ function MessageNotifications() {
|
||||
);
|
||||
}
|
||||
|
||||
function QuickSwitcherFeature() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
return <QuickSwitcher onClose={() => setOpen(false)} />;
|
||||
}
|
||||
|
||||
type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -274,6 +295,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<PresenceUpdater />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<QuickSwitcherFeature />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -42,9 +42,42 @@ import {
|
||||
import { onEnterOrSpace } from '../utils/keyboard';
|
||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
||||
|
||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||
|
||||
/** Languages handled by the custom TDS tokenizer. */
|
||||
const TDS_TOKENIZER_LANGS = new Set([
|
||||
'js',
|
||||
'javascript',
|
||||
'ts',
|
||||
'typescript',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'py',
|
||||
'python',
|
||||
'rs',
|
||||
'rust',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Renders a code string as an array of coloured <span> elements using the
|
||||
* lightweight TDS tokenizer. Falls back to a plain text node when the
|
||||
* language is not in the supported set.
|
||||
*/
|
||||
function renderTokenizedCode(code: string, lang: string): React.ReactNode {
|
||||
const normalised = lang.toLowerCase().replace(/^language-/, '');
|
||||
if (!TDS_TOKENIZER_LANGS.has(normalised)) return code;
|
||||
|
||||
const tokens = tokenize(code, normalised);
|
||||
|
||||
return tokens.map((tok, idx) => (
|
||||
<span key={idx} style={tok.type !== 'plain' ? tokenStyle(tok.type) : undefined}>
|
||||
{tok.text}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
@@ -420,6 +453,18 @@ export const getReactCustomHtmlParser = (
|
||||
if (lang === 'language-rs') lang = 'language-rust';
|
||||
else if (lang === 'language-js') lang = 'language-javascript';
|
||||
else if (lang === 'language-ts') lang = 'language-typescript';
|
||||
|
||||
// Use lightweight TDS tokenizer for supported languages to render
|
||||
// coloured <span> elements with inline TDS CSS variable styles.
|
||||
const normLang = (lang ?? '').toLowerCase().replace(/^language-/, '');
|
||||
if (TDS_TOKENIZER_LANGS.has(normLang)) {
|
||||
return (
|
||||
<code {...props} className={lang}>
|
||||
{renderTokenizedCode(codeReact, normLang)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
||||
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
||||
|
||||
@@ -22,6 +22,54 @@
|
||||
--prism-regex: #fd971f;
|
||||
}
|
||||
|
||||
/* ── Lotus Terminal Design System syntax theme ─────────────────────────────
|
||||
Applied when the lotus-terminal-theme body class is active.
|
||||
Maps Prism token roles to TDS accent variables:
|
||||
keyword → --lt-accent-cyan (language control flow)
|
||||
selector → --lt-accent-green (strings / inserted text)
|
||||
boolean → --lt-accent-orange (numbers / booleans)
|
||||
atrule → --lt-accent-purple (functions / class names)
|
||||
comment → dimmed italic (comments use opacity)
|
||||
property → --lt-accent-orange (properties / tags)
|
||||
regex → --lt-accent-amber (regex / important)
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
.prism-tds-dark {
|
||||
--prism-comment: rgba(0, 255, 136, 0.4);
|
||||
--prism-punctuation: rgba(196, 217, 238, 0.65);
|
||||
--prism-property: var(--lt-accent-orange, #ff6b00);
|
||||
--prism-boolean: var(--lt-accent-orange, #ff6b00);
|
||||
--prism-selector: var(--lt-accent-green, #00ff88);
|
||||
--prism-operator: rgba(196, 217, 238, 0.8);
|
||||
--prism-atrule: var(--lt-accent-purple, #bf5fff);
|
||||
--prism-keyword: var(--lt-accent-cyan, #00d4ff);
|
||||
--prism-regex: var(--lt-accent-amber, #ffb300);
|
||||
}
|
||||
|
||||
.prism-tds-light {
|
||||
--prism-comment: rgba(0, 109, 53, 0.55);
|
||||
--prism-punctuation: rgba(45, 61, 86, 0.7);
|
||||
--prism-property: var(--lt-accent-orange, #c44e00);
|
||||
--prism-boolean: var(--lt-accent-orange, #c44e00);
|
||||
--prism-selector: var(--lt-accent-green, #006d35);
|
||||
--prism-operator: rgba(45, 61, 86, 0.85);
|
||||
--prism-atrule: var(--lt-accent-purple, #6b2fb8);
|
||||
--prism-keyword: var(--lt-accent-cyan, #0062b8);
|
||||
--prism-regex: var(--lt-accent-amber, #8a5a00);
|
||||
}
|
||||
|
||||
/* Comment tokens get italic treatment in TDS themes */
|
||||
.prism-tds-dark code .token.comment,
|
||||
.prism-tds-dark code .token.prolog,
|
||||
.prism-tds-dark code .token.doctype,
|
||||
.prism-tds-dark code .token.cdata,
|
||||
.prism-tds-light code .token.comment,
|
||||
.prism-tds-light code .token.prolog,
|
||||
.prism-tds-light code .token.doctype,
|
||||
.prism-tds-light code .token.cdata {
|
||||
font-style: italic;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
code .token.comment,
|
||||
code .token.prolog,
|
||||
code .token.doctype,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Lightweight syntax tokenizer for code blocks.
|
||||
*
|
||||
* Returns an array of {text, type} tokens that can be rendered as
|
||||
* coloured <span> elements using TDS (Lotus Terminal Design System)
|
||||
* CSS custom properties via inline styles.
|
||||
*
|
||||
* Supported token types:
|
||||
* 'kw' → keywords → var(--lt-accent-cyan)
|
||||
* 'str' → strings → var(--lt-accent-green)
|
||||
* 'num' → numbers → var(--lt-accent-orange)
|
||||
* 'cmt' → comments → opacity 0.5, fontStyle italic
|
||||
* 'fn' → function names → var(--lt-accent-purple)
|
||||
* 'plain' → everything else → inherit
|
||||
*
|
||||
* Supported languages: javascript / typescript / python / rust (and aliases).
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type SyntaxToken = {
|
||||
text: string;
|
||||
type: 'kw' | 'str' | 'num' | 'cmt' | 'fn' | 'plain';
|
||||
};
|
||||
|
||||
// ── Language keyword sets ──────────────────────────────────────────────────
|
||||
|
||||
const JS_KEYWORDS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'false',
|
||||
'finally',
|
||||
'for',
|
||||
'from',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'let',
|
||||
'new',
|
||||
'null',
|
||||
'of',
|
||||
'return',
|
||||
'static',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'true',
|
||||
'try',
|
||||
'typeof',
|
||||
'undefined',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'async',
|
||||
'await',
|
||||
'type',
|
||||
'interface',
|
||||
'enum',
|
||||
'declare',
|
||||
'abstract',
|
||||
'as',
|
||||
'namespace',
|
||||
'module',
|
||||
'readonly',
|
||||
]);
|
||||
|
||||
const PYTHON_KEYWORDS = new Set([
|
||||
'False',
|
||||
'None',
|
||||
'True',
|
||||
'and',
|
||||
'as',
|
||||
'assert',
|
||||
'async',
|
||||
'await',
|
||||
'break',
|
||||
'class',
|
||||
'continue',
|
||||
'def',
|
||||
'del',
|
||||
'elif',
|
||||
'else',
|
||||
'except',
|
||||
'finally',
|
||||
'for',
|
||||
'from',
|
||||
'global',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'is',
|
||||
'lambda',
|
||||
'nonlocal',
|
||||
'not',
|
||||
'or',
|
||||
'pass',
|
||||
'raise',
|
||||
'return',
|
||||
'try',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
]);
|
||||
|
||||
const RUST_KEYWORDS = new Set([
|
||||
'as',
|
||||
'async',
|
||||
'await',
|
||||
'break',
|
||||
'const',
|
||||
'continue',
|
||||
'crate',
|
||||
'dyn',
|
||||
'else',
|
||||
'enum',
|
||||
'extern',
|
||||
'false',
|
||||
'fn',
|
||||
'for',
|
||||
'if',
|
||||
'impl',
|
||||
'in',
|
||||
'let',
|
||||
'loop',
|
||||
'match',
|
||||
'mod',
|
||||
'move',
|
||||
'mut',
|
||||
'pub',
|
||||
'ref',
|
||||
'return',
|
||||
'self',
|
||||
'Self',
|
||||
'static',
|
||||
'struct',
|
||||
'super',
|
||||
'trait',
|
||||
'true',
|
||||
'type',
|
||||
'union',
|
||||
'unsafe',
|
||||
'use',
|
||||
'where',
|
||||
'while',
|
||||
]);
|
||||
|
||||
function getKeywords(lang: string): Set<string> {
|
||||
const l = lang.toLowerCase();
|
||||
if (l === 'python' || l === 'py') return PYTHON_KEYWORDS;
|
||||
if (l === 'rust' || l === 'rs') return RUST_KEYWORDS;
|
||||
// js / ts / jsx / tsx and friends
|
||||
return JS_KEYWORDS;
|
||||
}
|
||||
|
||||
// ── Tokenizer ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tokenises `code` for the given `lang` and returns an array of SyntaxToken
|
||||
* objects. Falls back to a single 'plain' token when the language is not
|
||||
* recognised or when `lang` is empty.
|
||||
*/
|
||||
export function tokenize(code: string, lang: string): SyntaxToken[] {
|
||||
const normalised = lang.toLowerCase().replace(/^language-/, '');
|
||||
|
||||
const supported = [
|
||||
'js',
|
||||
'javascript',
|
||||
'ts',
|
||||
'typescript',
|
||||
'jsx',
|
||||
'tsx',
|
||||
'py',
|
||||
'python',
|
||||
'rs',
|
||||
'rust',
|
||||
];
|
||||
if (!supported.includes(normalised)) {
|
||||
return [{ text: code, type: 'plain' }];
|
||||
}
|
||||
|
||||
const keywords = getKeywords(normalised);
|
||||
const tokens: SyntaxToken[] = [];
|
||||
let i = 0;
|
||||
const len = code.length;
|
||||
|
||||
while (i < len) {
|
||||
// ── Block comment /* … */ ──────────────────────────────────────────────
|
||||
if (code[i] === '/' && code[i + 1] === '*') {
|
||||
const end = code.indexOf('*/', i + 2);
|
||||
const closeIdx = end === -1 ? len : end + 2;
|
||||
tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' });
|
||||
i = closeIdx;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Line comment // … ──────────────────────────────────────────────────
|
||||
if (code[i] === '/' && code[i + 1] === '/') {
|
||||
const nl = code.indexOf('\n', i);
|
||||
const closeIdx = nl === -1 ? len : nl;
|
||||
tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' });
|
||||
i = closeIdx;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Python / shell line comment # … ──────────────────────────────────
|
||||
if (
|
||||
code[i] === '#' &&
|
||||
(normalised === 'python' || normalised === 'py') &&
|
||||
(i === 0 || code[i - 1] === '\n')
|
||||
) {
|
||||
const nlHash = code.indexOf('\n', i);
|
||||
const closeIdx = nlHash === -1 ? len : nlHash;
|
||||
tokens.push({ text: code.slice(i, closeIdx), type: 'cmt' });
|
||||
i = closeIdx;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── String literals (single, double, backtick) ─────────────────────────
|
||||
const quote = code[i];
|
||||
if (quote === '"' || quote === "'" || quote === '`') {
|
||||
let j = i + 1;
|
||||
while (j < len) {
|
||||
if (code[j] === '\\') {
|
||||
j += 2; // skip escaped char
|
||||
} else if (code[j] === quote) {
|
||||
j += 1;
|
||||
break;
|
||||
} else if (quote !== '`' && code[j] === '\n') {
|
||||
// unterminated single/double quote — stop at newline
|
||||
break;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
tokens.push({ text: code.slice(i, j), type: 'str' });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Numbers ────────────────────────────────────────────────────────────
|
||||
if (/\d/.test(code[i]) && (i === 0 || /\W/.test(code[i - 1]))) {
|
||||
let j = i;
|
||||
while (j < len && /[\d._xXbBoOeE]/.test(code[j])) j++;
|
||||
tokens.push({ text: code.slice(i, j), type: 'num' });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Identifiers (keywords, function names, plain words) ───────────────
|
||||
if (/[a-zA-Z_$]/.test(code[i])) {
|
||||
let j = i;
|
||||
while (j < len && /[a-zA-Z0-9_$]/.test(code[j])) j++;
|
||||
const word = code.slice(i, j);
|
||||
|
||||
// Look ahead for `(` to detect function calls / definitions
|
||||
let k = j;
|
||||
while (k < len && (code[k] === ' ' || code[k] === '\t')) k++;
|
||||
const isFunctionCall = code[k] === '(';
|
||||
|
||||
if (keywords.has(word)) {
|
||||
tokens.push({ text: word, type: 'kw' });
|
||||
} else if (isFunctionCall) {
|
||||
tokens.push({ text: word, type: 'fn' });
|
||||
} else {
|
||||
tokens.push({ text: word, type: 'plain' });
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Everything else — collect a run of non-special chars ──────────────
|
||||
const start = i;
|
||||
while (
|
||||
i < len &&
|
||||
code[i] !== '/' &&
|
||||
code[i] !== '#' &&
|
||||
code[i] !== '"' &&
|
||||
code[i] !== "'" &&
|
||||
code[i] !== '`' &&
|
||||
!/[a-zA-Z0-9_$]/.test(code[i])
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
if (i === start) i++; // safety: always advance
|
||||
if (start < i) tokens.push({ text: code.slice(start, i), type: 'plain' });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Inline style helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the React inline-style object for a given SyntaxToken type. */
|
||||
export function tokenStyle(type: SyntaxToken['type']): CSSProperties {
|
||||
switch (type) {
|
||||
case 'kw':
|
||||
return { color: 'var(--lt-accent-cyan, #66d9ef)' };
|
||||
case 'str':
|
||||
return { color: 'var(--lt-accent-green, #a6e22e)' };
|
||||
case 'num':
|
||||
return { color: 'var(--lt-accent-orange, #fd971f)' };
|
||||
case 'cmt':
|
||||
return { opacity: 0.5, fontStyle: 'italic' as const };
|
||||
case 'fn':
|
||||
return { color: 'var(--lt-accent-purple, #ae81ff)' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user