Compare commits
3 Commits
90c5325618
...
82840dc4e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 82840dc4e2 | |||
| 2adf3b4ad2 | |||
| f7c39e20a9 |
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,22 @@ export function AudioContent({
|
|||||||
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
|
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = playbackSpeed;
|
||||||
|
}
|
||||||
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
|
|
||||||
|
const handleSpeedClick = () => {
|
||||||
|
const currentIndex = SPEED_STEPS.indexOf(playbackSpeed);
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_STEPS.length;
|
||||||
|
setPlaybackSpeed(SPEED_STEPS[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (srcState.status === AsyncStatus.Success) {
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
setPlaying(!playing);
|
setPlaying(!playing);
|
||||||
@@ -163,6 +179,15 @@ export function AudioContent({
|
|||||||
<Text size="T200">{`${secondsToMinutesAndSeconds(
|
<Text size="T200">{`${secondsToMinutesAndSeconds(
|
||||||
currentTime,
|
currentTime,
|
||||||
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
onClick={handleSpeedClick}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
aria-label={`Playback speed: ${playbackSpeed}×`}
|
||||||
|
>
|
||||||
|
<Text size="B300">{`${playbackSpeed}×`}</Text>
|
||||||
|
</Chip>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
rightControl: (
|
rightControl: (
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
as,
|
as,
|
||||||
|
color,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { JoinRule, 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';
|
||||||
import { getMemberDisplayName, getStateEvent } from '../../utils/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 mDirects = useAtomValue(mDirectAtom);
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const [viewTopic, setViewTopic] = 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 createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
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>
|
<Text size="B300">Join Old Room</Text>
|
||||||
</Button>
|
</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>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { CutoutCard } from '../../../components/cutout-card';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||||
|
|
||||||
|
export function RoomShareInvite() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const domain = mx.getDomain() ?? undefined;
|
||||||
|
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
||||||
|
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(inviteUrl).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, [inviteUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Share Room"
|
||||||
|
description="Share this invite link so others can join the room."
|
||||||
|
/>
|
||||||
|
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
userSelect: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inviteUrl}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant={copied ? 'Success' : 'Secondary'}
|
||||||
|
fill={copied ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleCopy}
|
||||||
|
before={<Icon size="100" src={copied ? Icons.Check : Icons.Link} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{copied ? 'Copied!' : 'Copy Link'}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="Center">
|
||||||
|
<img
|
||||||
|
src={qrSrc}
|
||||||
|
alt="QR code for room invite link"
|
||||||
|
width={160}
|
||||||
|
height={160}
|
||||||
|
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CutoutCard>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ export * from './RoomHistoryVisibility';
|
|||||||
export * from './RoomJoinRules';
|
export * from './RoomJoinRules';
|
||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
import { useFocusWithin, useHover } from 'react-aria';
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
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 { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
@@ -67,8 +70,31 @@ import { callChatAtom } from '../../state/callEmbed';
|
|||||||
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
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 = {
|
type RenameRoomDialogProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -212,6 +238,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
if (isFavorite) {
|
||||||
|
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
|
} else {
|
||||||
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
requestClose();
|
requestClose();
|
||||||
@@ -273,6 +310,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
</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
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Star} filled={isFavorite} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={isFavorite}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -381,6 +429,7 @@ function RoomNavItem_({
|
|||||||
notificationMode,
|
notificationMode,
|
||||||
linkPath,
|
linkPath,
|
||||||
}: RoomNavItemProps) {
|
}: RoomNavItemProps) {
|
||||||
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
@@ -396,6 +445,28 @@ function RoomNavItem_({
|
|||||||
const roomName = useLocalRoomName(room);
|
const roomName = useLocalRoomName(room);
|
||||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
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) => {
|
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
setMenuAnchor({
|
setMenuAnchor({
|
||||||
@@ -487,6 +558,7 @@ function RoomNavItem_({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
@@ -499,6 +571,35 @@ function RoomNavItem_({
|
|||||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
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>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RoomLocalAddresses,
|
RoomLocalAddresses,
|
||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
} from '../../common-settings/general';
|
} from '../../common-settings/general';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
@@ -58,6 +59,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomPublishedAddresses permissions={permissions} />
|
<RoomPublishedAddresses permissions={permissions} />
|
||||||
<RoomLocalAddresses permissions={permissions} />
|
<RoomLocalAddresses permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Share</Text>
|
||||||
|
<RoomShareInvite />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Advanced Options</Text>
|
<Text size="L400">Advanced Options</Text>
|
||||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||||
|
|||||||
@@ -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,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Membership } from '../../../types/matrix/room';
|
||||||
|
|
||||||
import * as css from './MembersDrawer.css';
|
import * as css from './MembersDrawer.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -51,7 +53,11 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
||||||
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
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 { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
||||||
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
||||||
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
||||||
@@ -225,6 +231,15 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
|
|
||||||
const typingMembers = useRoomTypingMember(room.roomId);
|
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(
|
const filteredMembers = useMemo(
|
||||||
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
||||||
[members, membershipFilter, memberSort, memberPowerSort],
|
[members, membershipFilter, memberSort, memberPowerSort],
|
||||||
@@ -392,6 +407,78 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</ScrollTopContainer>
|
</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 && (
|
{!fetchingMembers && !result && processMembers.length === 0 && (
|
||||||
<Text style={{ padding: config.space.S300 }} align="Center">
|
<Text style={{ padding: config.space.S300 }} align="Center">
|
||||||
{`No "${membershipFilter.name}" Members`}
|
{`No "${membershipFilter.name}" Members`}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Box, Icon, IconButton, Icons, Text, config } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
interface PollCreatorProps {
|
||||||
|
roomId: string;
|
||||||
|
room: Room;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [options, setOptions] = useState<string[]>(['', '']);
|
||||||
|
const [maxSelections, setMaxSelections] = useState<number>(1);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOptionChange = (index: number, value: string) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOption = () => {
|
||||||
|
if (options.length >= 10) return;
|
||||||
|
setOptions((prev) => [...prev, '']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveOption = (index: number) => {
|
||||||
|
if (options.length <= 2) return;
|
||||||
|
setOptions((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const trimmedQuestion = question.trim();
|
||||||
|
if (!trimmedQuestion) {
|
||||||
|
setError('Please enter a question.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filledOptions = options.map((o) => o.trim()).filter((o) => o.length > 0);
|
||||||
|
if (filledOptions.length < 2) {
|
||||||
|
setError('Please provide at least 2 answer options.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||||
|
'm.poll': {
|
||||||
|
question: { 'm.text': trimmedQuestion },
|
||||||
|
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||||
|
max_selections: maxSelections,
|
||||||
|
kind: 'm.poll.undisclosed',
|
||||||
|
},
|
||||||
|
body: trimmedQuestion,
|
||||||
|
msgtype: 'm.text',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send poll.');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
padding: config.space.S500,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '420px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S300,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.24)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Row" alignItems="Center" justifyContent="SpaceBetween">
|
||||||
|
<Text size="H4">Create Poll</Text>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close poll creator"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Question</Text>
|
||||||
|
<input
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
placeholder="Ask a question..."
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Options</Text>
|
||||||
|
{options.map((opt, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => handleRemoveOption(index)}
|
||||||
|
disabled={options.length <= 2}
|
||||||
|
aria-label={`Remove option ${index + 1}`}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{options.length < 10 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddOption}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--tc-surface-low)',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: `${config.space.S100} 0`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
<span>Add Option</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Selection Type</Text>
|
||||||
|
<div style={{ display: 'flex', gap: config.space.S200 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pollType"
|
||||||
|
checked={maxSelections === 1}
|
||||||
|
onChange={() => setMaxSelections(1)}
|
||||||
|
/>
|
||||||
|
Single choice
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pollType"
|
||||||
|
checked={maxSelections !== 1}
|
||||||
|
onChange={() => setMaxSelections(options.length)}
|
||||||
|
/>
|
||||||
|
Multiple choice
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box direction="Row" justifyContent="End" gap="200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-primary-main)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||||
|
color: 'var(--tc-primary-on-primary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: submitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create Poll'}
|
||||||
|
</button>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
|
|||||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
|
import { MediaGallery } from './MediaGallery';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -431,6 +432,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
const searchParams: _SearchPathSearchParams = {
|
const searchParams: _SearchPathSearchParams = {
|
||||||
@@ -461,6 +463,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
className={ContainerColor({ variant: 'Surface' })}
|
className={ContainerColor({ variant: 'Surface' })}
|
||||||
balance={screenSize === ScreenSize.Mobile}
|
balance={screenSize === ScreenSize.Mobile}
|
||||||
@@ -641,6 +644,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
(direct ||
|
(direct ||
|
||||||
(room.getJoinRule() === 'invite' &&
|
(room.getJoinRule() === 'invite' &&
|
||||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
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 && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -714,5 +740,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -861,6 +861,10 @@ function Editor() {
|
|||||||
function Privacy() {
|
function Privacy() {
|
||||||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'privateReadReceipts',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -872,6 +876,19 @@ function Privacy() {
|
|||||||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Private Read Receipts"
|
||||||
|
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={privateReadReceipts}
|
||||||
|
onChange={setPrivateReadReceipts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Hide Online Status"
|
title="Hide Online Status"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const ButterTheme: Theme = {
|
|||||||
export const LotusTerminalTheme: Theme = {
|
export const LotusTerminalTheme: Theme = {
|
||||||
id: 'lotus-terminal-theme',
|
id: 'lotus-terminal-theme',
|
||||||
kind: ThemeKind.Dark,
|
kind: ThemeKind.Dark,
|
||||||
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-dark'],
|
classNames: ['lotus-terminal-theme', lotusTerminalTheme, onDarkFontWeight, 'prism-tds-dark'],
|
||||||
};
|
};
|
||||||
export const LotusTerminalLightTheme: Theme = {
|
export const LotusTerminalLightTheme: Theme = {
|
||||||
id: 'lotus-terminal-light-theme',
|
id: 'lotus-terminal-light-theme',
|
||||||
@@ -55,7 +55,7 @@ export const LotusTerminalLightTheme: Theme = {
|
|||||||
'lotus-terminal-light-theme',
|
'lotus-terminal-light-theme',
|
||||||
lotusTerminalLightTheme,
|
lotusTerminalLightTheme,
|
||||||
onLightFontWeight,
|
onLightFontWeight,
|
||||||
'prism-light',
|
'prism-tds-light',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
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 { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
|
import { QuickSwitcher } from '../../components/QuickSwitcher';
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
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 = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -274,6 +295,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<QuickSwitcherFeature />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
PopOut,
|
||||||
@@ -181,6 +182,7 @@ export function Direct() {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
const roomsWithUnreadSet = useAtomValue(
|
const roomsWithUnreadSet = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -216,8 +218,14 @@ export function Direct() {
|
|||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredDirects = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedDirects;
|
||||||
|
const q = filterQuery.toLowerCase();
|
||||||
|
return sortedDirects.filter((rId) => (mx.getRoom(rId)?.name ?? '').toLowerCase().includes(q));
|
||||||
|
}, [mx, sortedDirects, filterQuery]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: filteredDirects.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -253,6 +261,34 @@ export function Direct() {
|
|||||||
</NavButton>
|
</NavButton>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<Box style={{ padding: `0 ${config.space.S200}`, paddingBottom: config.space.S100 }}>
|
||||||
|
<Input
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFilterQuery(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Filter DMs…"
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
after={
|
||||||
|
filterQuery ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFilterQuery('')}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -270,7 +306,7 @@ export function Direct() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedDirects[vItem.index];
|
const roomId = filteredDirects[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
MouseEventHandler,
|
||||||
|
forwardRef,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -7,6 +14,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
PopOut,
|
||||||
@@ -66,6 +74,7 @@ import {
|
|||||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
|
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -201,12 +210,14 @@ function HomeEmpty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rooms = useHomeRooms();
|
const rooms = useHomeRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const [filterQuery, setFilterQuery] = useState<string>('');
|
||||||
// Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates
|
// Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates
|
||||||
const roomsWithUnreadSet = useAtomValue(
|
const roomsWithUnreadSet = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
@@ -235,8 +246,32 @@ export function Home() {
|
|||||||
const noRoomToDisplay = rooms.length === 0;
|
const noRoomToDisplay = rooms.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const { favoriteRooms, otherRooms } = useMemo(() => {
|
||||||
|
const favs: string[] = [];
|
||||||
|
const others: string[] = [];
|
||||||
|
rooms.forEach((rId) => {
|
||||||
|
const room = mx.getRoom(rId);
|
||||||
|
if (room?.tags?.['m.favourite']) {
|
||||||
|
favs.push(rId);
|
||||||
|
} else {
|
||||||
|
others.push(rId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { favoriteRooms: favs, otherRooms: others };
|
||||||
|
}, [mx, rooms]);
|
||||||
|
|
||||||
|
const sortedFavoriteRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(favoriteRooms).sort(
|
||||||
|
closedCategories.has(FAVORITES_CATEGORY_ID)
|
||||||
|
? factoryRoomIdByActivity(mx)
|
||||||
|
: factoryRoomIdByAtoZ(mx),
|
||||||
|
),
|
||||||
|
[mx, favoriteRooms, closedCategories],
|
||||||
|
);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const items = Array.from(rooms).sort(
|
const items = Array.from(otherRooms).sort(
|
||||||
closedCategories.has(DEFAULT_CATEGORY_ID)
|
closedCategories.has(DEFAULT_CATEGORY_ID)
|
||||||
? factoryRoomIdByActivity(mx)
|
? factoryRoomIdByActivity(mx)
|
||||||
: factoryRoomIdByAtoZ(mx),
|
: factoryRoomIdByAtoZ(mx),
|
||||||
@@ -245,10 +280,28 @@ export function Home() {
|
|||||||
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, rooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
}, [mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
|
const favVirtualizer = useVirtualizer({
|
||||||
|
count: sortedFavoriteRooms.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 38,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedRooms.length,
|
count: filteredRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -338,6 +391,73 @@ export function Home() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<Box
|
||||||
|
style={{ padding: `0 ${config.space.S200}`, paddingBottom: config.space.S100 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterQuery(e.target.value)}
|
||||||
|
placeholder="Filter rooms…"
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
after={
|
||||||
|
filterQuery ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFilterQuery('')}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</NavCategory>
|
||||||
|
{sortedFavoriteRooms.length > 0 && (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton
|
||||||
|
closed={closedCategories.has(FAVORITES_CATEGORY_ID)}
|
||||||
|
data-category-id={FAVORITES_CATEGORY_ID}
|
||||||
|
onClick={handleCategoryClick}
|
||||||
|
>
|
||||||
|
Favorites
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||||
|
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = sortedFavoriteRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={vItem.index}
|
||||||
|
ref={favVirtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selectedRoomId === roomId}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -355,7 +475,7 @@ export function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedRooms[vItem.index];
|
const roomId = filteredRooms[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|||||||
@@ -42,9 +42,42 @@ import {
|
|||||||
import { onEnterOrSpace } from '../utils/keyboard';
|
import { onEnterOrSpace } from '../utils/keyboard';
|
||||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||||
|
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
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');
|
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
||||||
|
|
||||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||||
@@ -420,6 +453,18 @@ export const getReactCustomHtmlParser = (
|
|||||||
if (lang === 'language-rs') lang = 'language-rust';
|
if (lang === 'language-rs') lang = 'language-rust';
|
||||||
else if (lang === 'language-js') lang = 'language-javascript';
|
else if (lang === 'language-js') lang = 'language-javascript';
|
||||||
else if (lang === 'language-ts') lang = 'language-typescript';
|
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 (
|
return (
|
||||||
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
||||||
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
||||||
|
|||||||
@@ -22,6 +22,54 @@
|
|||||||
--prism-regex: #fd971f;
|
--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.comment,
|
||||||
code .token.prolog,
|
code .token.prolog,
|
||||||
code .token.doctype,
|
code .token.doctype,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface Settings {
|
|||||||
pageZoom: number;
|
pageZoom: number;
|
||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
hidePresence: boolean;
|
hidePresence: boolean;
|
||||||
|
privateReadReceipts: boolean;
|
||||||
presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
|
|
||||||
isPeopleDrawer: boolean;
|
isPeopleDrawer: boolean;
|
||||||
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
|
|||||||
pageZoom: 100,
|
pageZoom: 100,
|
||||||
hideActivity: false,
|
hideActivity: false,
|
||||||
hidePresence: false,
|
hidePresence: false,
|
||||||
|
privateReadReceipts: false,
|
||||||
presenceStatus: 'auto',
|
presenceStatus: 'auto',
|
||||||
|
|
||||||
isPeopleDrawer: true,
|
isPeopleDrawer: true,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { getSettings } from '../state/settings';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
|
const { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
@@ -21,6 +23,6 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
|
|
||||||
await mx.sendReadReceipt(
|
await mx.sendReadReceipt(
|
||||||
latestEvent,
|
latestEvent,
|
||||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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