CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.
Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1067 lines
38 KiB
TypeScript
1067 lines
38 KiB
TypeScript
import React, {
|
|
MouseEventHandler,
|
|
forwardRef,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
Text,
|
|
Menu,
|
|
MenuItem,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
color,
|
|
config,
|
|
PopOut,
|
|
toRem,
|
|
Line,
|
|
RectCords,
|
|
Badge,
|
|
Spinner,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
} from 'folds';
|
|
import { useFocusWithin, useHover } from 'react-aria';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
import { selectAtom } from 'jotai/utils';
|
|
import {
|
|
isSectionTag,
|
|
listSectionNames,
|
|
sectionName,
|
|
sectionTag,
|
|
validateSectionName,
|
|
} from '../../utils/roomSections';
|
|
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|
import {
|
|
getDirectRoomAvatarUrl,
|
|
getMemberName,
|
|
getRoomAvatarUrl,
|
|
getStateEvent,
|
|
} from '../../utils/room';
|
|
import { nameInitials } from '../../utils/common';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useRoomUnread } from '../../state/hooks/unread';
|
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
|
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
|
import { hasMsgDraft } from '../../utils/draft';
|
|
import { DraftDot } from '../room/DraftIndicator.css';
|
|
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
|
import { markAsRead } from '../../utils/notifications';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
|
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
|
import { TypingIndicator } from '../../components/typing-indicator';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
|
import {
|
|
getRoomNotificationModeIcon,
|
|
RoomNotificationMode,
|
|
setRoomNotificationPreference,
|
|
} from '../../hooks/useRoomsNotificationPreferences';
|
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
|
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
|
import {
|
|
getLocalRoomNamesContent,
|
|
setLocalRoomName,
|
|
useHasLocalRoomName,
|
|
useLocalRoomName,
|
|
} from '../../hooks/useRoomMeta';
|
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
|
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
|
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
|
import { callChatAtom } from '../../state/callEmbed';
|
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
|
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
|
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
|
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
|
|
import { webRTCSupported } from '../../utils/rtc';
|
|
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
|
import { EmojiBoard } from '../../components/emoji-board';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
import { formatShortAge } from '../../utils/formatTimestamp';
|
|
|
|
const PREVIEW_MAX_CHARS = 48;
|
|
|
|
type RenameRoomDialogProps = {
|
|
room: Room;
|
|
onClose: () => void;
|
|
};
|
|
function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
|
const mx = useMatrixClient();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
|
|
|
const handleEmojiSelect = useCallback((unicode: string) => {
|
|
if (inputRef.current) {
|
|
inputRef.current.value = unicode + inputRef.current.value;
|
|
inputRef.current.focus();
|
|
}
|
|
setEmojiAnchor(undefined);
|
|
}, []);
|
|
|
|
const getCurrentLocalName = useCallback((): string => {
|
|
const content = getLocalRoomNamesContent(mx);
|
|
return content.rooms[room.roomId] ?? '';
|
|
}, [mx, room.roomId]);
|
|
|
|
const handleSave = useCallback(() => {
|
|
const newName = inputRef.current?.value.trim() ?? '';
|
|
if (newName.length > 255) return;
|
|
// Routed through the shared write queue (setLocalRoomName) instead of a
|
|
// read-modify-write against the SDK's local cache, which stays stale
|
|
// until the /sync echo lands and would otherwise let a second rename
|
|
// clobber a still-in-flight first rename.
|
|
setLocalRoomName(mx, room.roomId, newName);
|
|
onClose();
|
|
}, [mx, room.roomId, onClose]);
|
|
|
|
const handleClear = useCallback(() => {
|
|
setLocalRoomName(mx, room.roomId, '');
|
|
onClose();
|
|
}, [mx, room.roomId, onClose]);
|
|
|
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (evt.key === 'Enter') handleSave();
|
|
if (evt.key === 'Escape') onClose();
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
fallbackFocus: '#roomnavitem-dialog',
|
|
onDeactivate: onClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
id="roomnavitem-dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabIndex={-1}
|
|
variant="Surface"
|
|
aria-labelledby="rename-room-dialog-title"
|
|
>
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text as="h2" size="H4" id="rename-room-dialog-title">
|
|
Rename room (for you only)
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={onClose} radii="300" aria-label="Cancel">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
<Box direction="Column" gap="200">
|
|
<Text size="L400">Custom name</Text>
|
|
<Box direction="Row" gap="100" alignItems="Center">
|
|
<PopOut
|
|
anchor={emojiAnchor}
|
|
position="Top"
|
|
align="End"
|
|
content={
|
|
<EmojiBoard
|
|
imagePackRooms={[]}
|
|
returnFocusOnDeactivate={false}
|
|
onEmojiSelect={handleEmojiSelect}
|
|
requestClose={() => setEmojiAnchor(undefined)}
|
|
/>
|
|
}
|
|
>
|
|
<IconButton
|
|
type="button"
|
|
size="400"
|
|
radii="400"
|
|
variant="Surface"
|
|
fill="Soft"
|
|
outlined
|
|
aria-label="Insert emoji"
|
|
aria-expanded={!!emojiAnchor}
|
|
aria-haspopup="dialog"
|
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
|
const rect = evt.currentTarget.getBoundingClientRect();
|
|
setEmojiAnchor((prev) => (prev ? undefined : rect));
|
|
}}
|
|
>
|
|
<Icon src={Icons.Smile} size="400" />
|
|
</IconButton>
|
|
</PopOut>
|
|
<Box grow="Yes">
|
|
<Input
|
|
ref={inputRef}
|
|
defaultValue={getCurrentLocalName()}
|
|
placeholder={room.name}
|
|
variant="Secondary"
|
|
radii="300"
|
|
maxLength={255}
|
|
onKeyDown={handleKeyDown}
|
|
autoFocus
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
<Text size="T300" priority="300">
|
|
Only visible to you. Leave blank to use the original name.
|
|
</Text>
|
|
</Box>
|
|
<Box gap="300">
|
|
<Box grow="Yes">
|
|
<Button
|
|
onClick={handleClear}
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
style={{ width: '100%' }}
|
|
>
|
|
<Text size="B400">Clear</Text>
|
|
</Button>
|
|
</Box>
|
|
<Box grow="Yes">
|
|
<Button
|
|
onClick={handleSave}
|
|
variant="Primary"
|
|
radii="300"
|
|
style={{ width: '100%' }}
|
|
>
|
|
<Text size="B400">Save</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
type RoomNavItemMenuProps = {
|
|
room: Room;
|
|
requestClose: () => void;
|
|
onRenameClick: () => void;
|
|
notificationMode?: RoomNotificationMode;
|
|
};
|
|
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|
({ room, requestClose, onRenameClick, notificationMode }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
const powerLevels = usePowerLevels(room);
|
|
const creators = useRoomCreators(room);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
|
const openRoomSettings = useOpenRoomSettings();
|
|
const space = useSpaceOptionally();
|
|
const setToast = useSetAtom(toastQueueAtom);
|
|
|
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
|
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
|
// [Gitea #108] "Add to section" submenu: existing u.* sections + new one.
|
|
const [sectionMenuAnchor, setSectionMenuAnchor] = useState<RectCords>();
|
|
const [newSectionName, setNewSectionName] = useState('');
|
|
const [newSectionError, setNewSectionError] = useState<string>();
|
|
const sectionNames = useMemo(() => listSectionNames(mx), [mx]);
|
|
const roomSections = Object.keys(room.tags ?? {})
|
|
.filter(isSectionTag)
|
|
.map(sectionName);
|
|
const isServerNotice = room.getType() === 'm.server_notice';
|
|
|
|
const isFavorite = !!room.tags?.['m.favourite'];
|
|
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
|
|
|
// Surface a room-tag write failure instead of letting the toggle fail silently.
|
|
const notifyTagFailure = (err: unknown) => {
|
|
console.error('Failed to update room tag:', err);
|
|
setToast(
|
|
createErrorToast('Could not update this room. Please try again.', Icons.Warning, 'Failed'),
|
|
);
|
|
};
|
|
|
|
const handleToggleFavorite = () => {
|
|
if (isFavorite) {
|
|
mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
|
} else {
|
|
// Favourite and low-priority are mutually exclusive. Batch both writes so a
|
|
// failure surfaces a single toast, not one per operation.
|
|
const ops: Promise<unknown>[] = [mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 })];
|
|
if (isLowPriority) ops.push(mx.deleteRoomTag(room.roomId, 'm.lowpriority'));
|
|
Promise.all(ops).catch(notifyTagFailure);
|
|
}
|
|
requestClose();
|
|
};
|
|
|
|
const handleToggleLowPriority = () => {
|
|
if (isLowPriority) {
|
|
mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
|
} else {
|
|
const ops: Promise<unknown>[] = [
|
|
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }),
|
|
];
|
|
if (isFavorite) ops.push(mx.deleteRoomTag(room.roomId, 'm.favourite'));
|
|
Promise.all(ops).catch(notifyTagFailure);
|
|
}
|
|
requestClose();
|
|
};
|
|
|
|
const handleToggleSection = (name: string) => {
|
|
const tag = sectionTag(name);
|
|
const op = room.tags?.[tag]
|
|
? mx.deleteRoomTag(room.roomId, tag)
|
|
: mx.setRoomTag(room.roomId, tag, { order: 0.5 });
|
|
op.catch(notifyTagFailure);
|
|
requestClose();
|
|
};
|
|
|
|
const handleNewSection = (evt: React.FormEvent) => {
|
|
evt.preventDefault();
|
|
const error = validateSectionName(newSectionName, sectionNames);
|
|
if (error) {
|
|
setNewSectionError(error);
|
|
return;
|
|
}
|
|
handleToggleSection(newSectionName.trim());
|
|
};
|
|
|
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
|
|
|
const handleMarkAsRead = () => {
|
|
markAsRead(mx, room.roomId, hideActivity);
|
|
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
|
requestClose();
|
|
};
|
|
|
|
const handleMarkAsUnread = () => {
|
|
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
|
|
requestClose();
|
|
};
|
|
|
|
const handleMuteFor = useCallback(
|
|
async (durationMs: number | null) => {
|
|
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
|
|
await setRoomNotificationPreference(
|
|
mx,
|
|
room.roomId,
|
|
RoomNotificationMode.Mute,
|
|
prevMode,
|
|
).catch(() => {});
|
|
if (durationMs !== null) {
|
|
scheduleMuteTimer(room.roomId, durationMs, () => {
|
|
unmuteRoom(mx, room.roomId);
|
|
});
|
|
}
|
|
requestClose();
|
|
},
|
|
[mx, room.roomId, notificationMode, requestClose],
|
|
);
|
|
|
|
const handleInvite = () => {
|
|
setInvitePrompt(true);
|
|
};
|
|
|
|
const handleRoomSettings = () => {
|
|
openRoomSettings(room.roomId, space?.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const isMuted = notificationMode === RoomNotificationMode.Mute;
|
|
|
|
return (
|
|
<Menu ref={ref} style={{ maxWidth: toRem(180), width: '100vw' }}>
|
|
{invitePrompt && room && (
|
|
<InviteUserPrompt
|
|
room={room}
|
|
requestClose={() => {
|
|
setInvitePrompt(false);
|
|
requestClose();
|
|
}}
|
|
/>
|
|
)}
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<MenuItem
|
|
onClick={handleMarkAsRead}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
|
radii="300"
|
|
disabled={!unread && !markedUnread}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Mark as Read
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={handleMarkAsUnread}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.MessageUnread} />}
|
|
radii="300"
|
|
disabled={!!unread || markedUnread}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Mark as Unread
|
|
</Text>
|
|
</MenuItem>
|
|
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
|
{(handleOpen, opened, changing) => (
|
|
<MenuItem
|
|
size="300"
|
|
after={
|
|
changing ? (
|
|
<Spinner size="100" variant="Secondary" />
|
|
) : (
|
|
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
|
|
)
|
|
}
|
|
radii="300"
|
|
aria-pressed={opened}
|
|
onClick={handleOpen}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Notifications
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
</RoomNotificationModeSwitcher>
|
|
</Box>
|
|
{!isMuted && (
|
|
<>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<PopOut
|
|
anchor={muteMenuAnchor}
|
|
position="Right"
|
|
align="Start"
|
|
offset={4}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setMuteMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Menu style={{ maxWidth: toRem(160), width: '100vw' }}>
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
{[
|
|
{ label: '15 minutes', ms: 15 * 60 * 1000 },
|
|
{ label: '1 hour', ms: 60 * 60 * 1000 },
|
|
{ label: '8 hours', ms: 8 * 60 * 60 * 1000 },
|
|
{ label: '24 hours', ms: 24 * 60 * 60 * 1000 },
|
|
{ label: 'Indefinitely', ms: null },
|
|
].map(({ label, ms }) => (
|
|
<MenuItem
|
|
key={label}
|
|
size="300"
|
|
radii="300"
|
|
onClick={() => handleMuteFor(ms)}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
{label}
|
|
</Text>
|
|
</MenuItem>
|
|
))}
|
|
</Box>
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<MenuItem
|
|
size="300"
|
|
before={<Icon size="100" src={Icons.BellMute} />}
|
|
after={<Icon size="100" src={Icons.ChevronRight} />}
|
|
radii="300"
|
|
aria-pressed={!!muteMenuAnchor}
|
|
onClick={(e) => setMuteMenuAnchor(e.currentTarget.getBoundingClientRect())}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Mute
|
|
</Text>
|
|
</MenuItem>
|
|
</PopOut>
|
|
</Box>
|
|
</>
|
|
)}
|
|
<Line variant="Surface" size="300" />
|
|
<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
|
|
onClick={handleToggleLowPriority}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ChevronBottom} />}
|
|
radii="300"
|
|
aria-pressed={isLowPriority}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
|
</Text>
|
|
</MenuItem>
|
|
<PopOut
|
|
anchor={sectionMenuAnchor}
|
|
position="Right"
|
|
align="Start"
|
|
offset={4}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setSectionMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Menu style={{ maxWidth: toRem(220), width: '100vw' }}>
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
{sectionNames.map((name) => {
|
|
const inSection = roomSections.includes(name);
|
|
return (
|
|
<MenuItem
|
|
key={name}
|
|
size="300"
|
|
radii="300"
|
|
role="menuitemcheckbox"
|
|
aria-checked={inSection}
|
|
after={inSection ? <Icon size="100" src={Icons.Check} /> : undefined}
|
|
onClick={() => handleToggleSection(name)}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
{name}
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
})}
|
|
{sectionNames.length > 0 && <Line variant="Surface" size="300" />}
|
|
<Box
|
|
as="form"
|
|
direction="Column"
|
|
gap="100"
|
|
onSubmit={handleNewSection}
|
|
style={{ padding: config.space.S100 }}
|
|
>
|
|
<Input
|
|
size="300"
|
|
variant="Background"
|
|
radii="300"
|
|
placeholder="New section…"
|
|
aria-label="New section name"
|
|
value={newSectionName}
|
|
onChange={(e) => {
|
|
setNewSectionName(e.currentTarget.value);
|
|
setNewSectionError(undefined);
|
|
}}
|
|
after={
|
|
<IconButton
|
|
type="submit"
|
|
size="300"
|
|
radii="300"
|
|
variant="Background"
|
|
aria-label="Create section"
|
|
>
|
|
<Icon size="100" src={Icons.Plus} />
|
|
</IconButton>
|
|
}
|
|
/>
|
|
{newSectionError && (
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
{newSectionError}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ChevronRight} />}
|
|
radii="300"
|
|
aria-pressed={!!sectionMenuAnchor}
|
|
aria-haspopup="menu"
|
|
onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
{roomSections.length > 0
|
|
? `Sections: ${roomSections.join(', ')}`
|
|
: 'Add to Section'}
|
|
</Text>
|
|
</MenuItem>
|
|
</PopOut>
|
|
<MenuItem
|
|
onClick={handleInvite}
|
|
variant="Primary"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.UserPlus} />}
|
|
radii="300"
|
|
aria-pressed={invitePrompt}
|
|
disabled={!canInvite}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Invite
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={() => {
|
|
requestClose();
|
|
onRenameClick();
|
|
}}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Pencil} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Rename for me…
|
|
</Text>
|
|
</MenuItem>
|
|
{!isServerNotice && (
|
|
<MenuItem
|
|
onClick={handleRoomSettings}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Setting} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Room Settings
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
</Box>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<UseStateProvider initial={false}>
|
|
{(promptLeave, setPromptLeave) => (
|
|
<>
|
|
<MenuItem
|
|
onClick={() => setPromptLeave(true)}
|
|
variant="Critical"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
|
radii="300"
|
|
aria-pressed={promptLeave}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Leave Room
|
|
</Text>
|
|
</MenuItem>
|
|
{promptLeave && (
|
|
<LeaveRoomPrompt
|
|
roomId={room.roomId}
|
|
onDone={requestClose}
|
|
onCancel={() => setPromptLeave(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
</Menu>
|
|
);
|
|
},
|
|
);
|
|
|
|
function CallChatToggle() {
|
|
const [chat, setChat] = useAtom(callChatAtom);
|
|
|
|
return (
|
|
<IconButton
|
|
onClick={() => setChat(!chat)}
|
|
aria-pressed={chat}
|
|
aria-label="Toggle Chat"
|
|
variant="Background"
|
|
fill="None"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Icon size="50" src={Icons.Message} filled={chat} />
|
|
</IconButton>
|
|
);
|
|
}
|
|
|
|
type RoomNavItemProps = {
|
|
room: Room;
|
|
selected: boolean;
|
|
linkPath: string;
|
|
notificationMode?: RoomNotificationMode;
|
|
showAvatar?: boolean;
|
|
direct?: boolean;
|
|
};
|
|
function RoomNavItem_({
|
|
room,
|
|
selected,
|
|
showAvatar,
|
|
direct,
|
|
notificationMode,
|
|
linkPath,
|
|
}: RoomNavItemProps) {
|
|
const isFavorite = !!room.tags?.['m.favourite'];
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [hover, setHover] = useState(false);
|
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const [renameDialog, setRenameDialog] = useState(false);
|
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
// MSC2867: an explicit "mark as unread" lights the row even with no unread
|
|
// count. `hasUnread` drives the bold name / icon emphasis below.
|
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
|
const hasUnread = !!unread || markedUnread;
|
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
|
(receipt) => receipt.userId !== mx.getUserId(),
|
|
);
|
|
|
|
const roomName = useLocalRoomName(room);
|
|
const hasLocalName = useHasLocalRoomName(room.roomId);
|
|
const { prefs } = useTimestampFormatter();
|
|
|
|
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
|
// so the row only re-renders when that flips (the draft atom itself is written
|
|
// on room-leave, not per keystroke).
|
|
const hasDraftAtom = useMemo(
|
|
() => selectAtom(roomIdToMsgDraftAtomFamily(room.roomId), hasMsgDraft),
|
|
[room.roomId],
|
|
);
|
|
const hasDraft = useAtomValue(hasDraftAtom);
|
|
|
|
// Only DM rows render this preview — pass `direct` so non-DM nav items don't
|
|
// register the global decryption listener (PERF-5).
|
|
const latestEvent = useRoomLatestRenderedEvent(room, !!direct);
|
|
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.isDecryptionFailure()) {
|
|
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: formatShortAge(ts, prefs) };
|
|
})();
|
|
|
|
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
|
evt.preventDefault();
|
|
setMenuAnchor({
|
|
x: evt.clientX,
|
|
y: evt.clientY,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
};
|
|
|
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
|
};
|
|
|
|
const optionsVisible = hover || !!menuAnchor;
|
|
const callSession = useCallSession(room);
|
|
const callMembers = useCallMembers(callSession);
|
|
const liveNames = useMemo(() => {
|
|
const names = Array.from(
|
|
new Set(callMembers.map((m) => m.sender).filter((u): u is string => !!u)),
|
|
).map((u) => getMemberName(room, u));
|
|
const shown = names.slice(0, 6);
|
|
const more = names.length - shown.length;
|
|
return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
|
|
}, [callMembers, room]);
|
|
const startCall = useCallStart(direct);
|
|
const callEmbed = useCallEmbed();
|
|
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
|
|
// second-click join path can't bypass a full channel.
|
|
const { channelFull, current: voiceCurrent, max: voiceMax } = useVoiceChannelFull(room);
|
|
const callPref = useAtomValue(useCallPreferencesAtom());
|
|
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
|
const setToast = useSetAtom(toastQueueAtom);
|
|
|
|
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
|
|
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
|
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
|
|
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
|
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
|
|
|
const hasCallPermission = permissions.stateEvent(
|
|
StateEvent.GroupCallMemberPrefix,
|
|
mx.getSafeUserId(),
|
|
);
|
|
|
|
// Do not join if missing permissions or no livekit support or no webRTC support
|
|
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
|
|
return;
|
|
}
|
|
|
|
// Do not join if already in call
|
|
if (callEmbed) {
|
|
return;
|
|
}
|
|
|
|
// [Gitea #30] Refuse to start a call into a full voice channel — the
|
|
// prescreen already blocks this, but the sidebar second-click join path
|
|
// skipped the check entirely.
|
|
if (channelFull) {
|
|
evt.preventDefault();
|
|
setToast(
|
|
createErrorToast(
|
|
`Channel full (${voiceCurrent}/${voiceMax})`,
|
|
Icons.Warning,
|
|
'Cannot join',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Start call in second click
|
|
if (selected) {
|
|
evt.preventDefault();
|
|
startCall(room, callPref);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<NavItem
|
|
variant="Background"
|
|
radii="400"
|
|
highlight={hasUnread}
|
|
aria-selected={selected}
|
|
data-hover={!!menuAnchor}
|
|
onContextMenu={handleContextMenu}
|
|
{...hoverProps}
|
|
{...focusWithinProps}
|
|
>
|
|
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
<Avatar size="200" radii="400">
|
|
{showAvatar ? (
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={
|
|
direct
|
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
|
}
|
|
alt={roomName}
|
|
renderFallback={() => (
|
|
<Text as="span" size="H6">
|
|
{nameInitials(roomName)}
|
|
</Text>
|
|
)}
|
|
/>
|
|
) : (
|
|
<RoomIcon
|
|
style={{
|
|
opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
|
|
}}
|
|
filled={selected}
|
|
size="100"
|
|
joinRule={room.getJoinRule()}
|
|
roomType={room.getType()}
|
|
/>
|
|
)}
|
|
</Avatar>
|
|
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
|
<Text priority={hasUnread ? '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 }}
|
|
/>
|
|
)}
|
|
{hasDraft && !selected && (
|
|
<span className={DraftDot} role="img" aria-label="Unsent draft" />
|
|
)}
|
|
</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 && !hasUnread && !selected && typingMember.length > 0 && (
|
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
|
<TypingIndicator size="300" disableAnimation />
|
|
</Badge>
|
|
)}
|
|
{!optionsVisible && unread && (
|
|
<UnreadBadgeCenter>
|
|
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
|
</UnreadBadgeCenter>
|
|
)}
|
|
{!optionsVisible && !unread && markedUnread && (
|
|
<UnreadBadgeCenter>
|
|
<UnreadBadge highlight={false} count={0} />
|
|
</UnreadBadgeCenter>
|
|
)}
|
|
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
|
<Icon
|
|
size="50"
|
|
src={getRoomNotificationModeIcon(notificationMode)}
|
|
aria-label={notificationMode}
|
|
/>
|
|
)}
|
|
{callMembers.length > 0 && (
|
|
// [Gitea #157] The count alone doesn't tell you whether it's your
|
|
// squad — name the people on hover/focus (long-press on touch).
|
|
<TooltipProvider
|
|
position="Right"
|
|
delay={300}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text size="T200">{liveNames}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(tipRef) => (
|
|
<Badge
|
|
ref={tipRef}
|
|
variant="Critical"
|
|
fill="Solid"
|
|
size="400"
|
|
tabIndex={0}
|
|
aria-label={`${callMembers.length} in call: ${liveNames}`}
|
|
>
|
|
<Text as="span" size="L400" truncate>
|
|
{callMembers.length} Live
|
|
</Text>
|
|
</Badge>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavLink>
|
|
{optionsVisible && (
|
|
<NavItemOptions>
|
|
{selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && (
|
|
<CallChatToggle />
|
|
)}
|
|
<PopOut
|
|
id={`menu-${room.roomId}`}
|
|
aria-expanded={!!menuAnchor}
|
|
anchor={menuAnchor}
|
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
|
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
|
position="Bottom"
|
|
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<RoomNavItemMenu
|
|
room={room}
|
|
requestClose={() => setMenuAnchor(undefined)}
|
|
onRenameClick={() => setRenameDialog(true)}
|
|
notificationMode={notificationMode}
|
|
/>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<IconButton
|
|
onClick={handleOpenMenu}
|
|
aria-pressed={!!menuAnchor}
|
|
aria-controls={`menu-${room.roomId}`}
|
|
aria-label="More Options"
|
|
variant="Background"
|
|
fill="None"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Icon size="50" src={Icons.VerticalDots} />
|
|
</IconButton>
|
|
</PopOut>
|
|
</NavItemOptions>
|
|
)}
|
|
{renameDialog && <RenameRoomDialog room={room} onClose={() => setRenameDialog(false)} />}
|
|
</NavItem>
|
|
);
|
|
}
|
|
export const RoomNavItem = React.memo(RoomNavItem_);
|