Files
cinny/src/app/features/room-nav/RoomNavItem.tsx
T
Lotus CIandClaude Opus 5.5 103c6f4624
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
fix(a11y): closing a header/sidebar menu returns focus to its button
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
2026-09-24 13:16:12 -04:00

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_);