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(null); const [emojiAnchor, setEmojiAnchor] = useState(); 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) => { if (evt.key === 'Enter') handleSave(); if (evt.key === 'Escape') onClose(); }; return ( }>
Rename room (for you only)
Custom name setEmojiAnchor(undefined)} /> } > ) => { const rect = evt.currentTarget.getBoundingClientRect(); setEmojiAnchor((prev) => (prev ? undefined : rect)); }} > Only visible to you. Leave blank to use the original name.
); } type RoomNavItemMenuProps = { room: Room; requestClose: () => void; onRenameClick: () => void; notificationMode?: RoomNotificationMode; }; const RoomNavItemMenu = forwardRef( ({ 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(); // [Gitea #108] "Add to section" submenu: existing u.* sections + new one. const [sectionMenuAnchor, setSectionMenuAnchor] = useState(); const [newSectionName, setNewSectionName] = useState(''); const [newSectionError, setNewSectionError] = useState(); 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[] = [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[] = [ 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 ( {invitePrompt && room && ( { setInvitePrompt(false); requestClose(); }} /> )} } radii="300" disabled={!unread && !markedUnread} > Mark as Read } radii="300" disabled={!!unread || markedUnread} > Mark as Unread {(handleOpen, opened, changing) => ( ) : ( ) } radii="300" aria-pressed={opened} onClick={handleOpen} > Notifications )} {!isMuted && ( <> setMuteMenuAnchor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > {[ { 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 }) => ( handleMuteFor(ms)} > {label} ))} } > } after={} radii="300" aria-pressed={!!muteMenuAnchor} onClick={(e) => setMuteMenuAnchor(e.currentTarget.getBoundingClientRect())} > Mute )} } radii="300" aria-pressed={isFavorite} > {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} } radii="300" aria-pressed={isLowPriority} > {isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'} setSectionMenuAnchor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > {sectionNames.map((name) => { const inSection = roomSections.includes(name); return ( : undefined} onClick={() => handleToggleSection(name)} > {name} ); })} {sectionNames.length > 0 && } { setNewSectionName(e.currentTarget.value); setNewSectionError(undefined); }} after={ } /> {newSectionError && ( {newSectionError} )} } > } radii="300" aria-pressed={!!sectionMenuAnchor} aria-haspopup="menu" onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())} > {roomSections.length > 0 ? `Sections: ${roomSections.join(', ')}` : 'Add to Section'} } radii="300" aria-pressed={invitePrompt} disabled={!canInvite} > Invite { requestClose(); onRenameClick(); }} size="300" after={} radii="300" > Rename for me… {!isServerNotice && ( } radii="300" > Room Settings )} {(promptLeave, setPromptLeave) => ( <> setPromptLeave(true)} variant="Critical" fill="None" size="300" after={} radii="300" aria-pressed={promptLeave} > Leave Room {promptLeave && ( setPromptLeave(false)} /> )} )} ); }, ); function CallChatToggle() { const [chat, setChat] = useAtom(callChatAtom); return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" variant="Background" fill="None" size="300" radii="300" > ); } 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(); 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 = (evt) => { evt.preventDefault(); setMenuAnchor({ x: evt.clientX, y: evt.clientY, width: 0, height: 0, }); }; const handleOpenMenu: MouseEventHandler = (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 = (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 ( {showAvatar ? ( ( {nameInitials(roomName)} )} /> ) : ( )} {roomName} {hasLocalName && ( )} {isFavorite && ( )} {hasDraft && !selected && ( )} {dmPreview && ( {dmPreview.preview} {dmPreview.time} )} {!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && ( )} {!optionsVisible && unread && ( 0} count={unread.total} /> )} {!optionsVisible && !unread && markedUnread && ( )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( )} {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). {liveNames} } > {(tipRef) => ( {callMembers.length} Live )} )} {optionsVisible && ( {selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && ( )} setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setMenuAnchor(undefined)} onRenameClick={() => setRenameDialog(true)} notificationMode={notificationMode} /> } > )} {renameDialog && setRenameDialog(false)} />} ); } export const RoomNavItem = React.memo(RoomNavItem_);