import React, { MouseEventHandler, forwardRef, useCallback, 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, config, PopOut, toRem, Line, RectCords, Badge, Spinner, } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import dayjs from 'dayjs'; import isToday from 'dayjs/plugin/isToday'; import isYesterday from 'dayjs/plugin/isYesterday'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, 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 { 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, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { LOCAL_ROOM_NAMES_KEY, getLocalRoomNamesContent, useHasLocalRoomName, useLocalRoomName, } from '../../hooks/useRoomMeta'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { callChatAtom } from '../../state/callEmbed'; 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'; dayjs.extend(isToday); dayjs.extend(isYesterday); const PREVIEW_MAX_CHARS = 48; function formatDmTimestamp(ts: number): string { const d = dayjs(ts); const now = dayjs(); const diffMinutes = now.diff(d, 'minute'); if (diffMinutes < 60) { return `${diffMinutes < 1 ? 0 : diffMinutes}m`; } const diffHours = now.diff(d, 'hour'); if (diffHours < 24) { return `${diffHours}h`; } if (d.isYesterday()) { return 'Yesterday'; } return d.format('D MMM'); } type RenameRoomDialogProps = { room: Room; onClose: () => void; }; function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) { const mx = useMatrixClient(); const inputRef = useRef(null); 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; const existing = getLocalRoomNamesContent(mx); if (newName === '') { const { [room.roomId]: _removed, ...rest } = existing.rooms; // eslint-disable-next-line @typescript-eslint/no-explicit-any (mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest }); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: { ...existing.rooms, [room.roomId]: newName }, }); } onClose(); }, [mx, room.roomId, onClose]); const handleClear = useCallback(() => { const existing = getLocalRoomNamesContent(mx); const { [room.roomId]: _removed, ...rest } = existing.rooms; // eslint-disable-next-line @typescript-eslint/no-explicit-any (mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest }); 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 Only visible to you. Leave blank to use the original name.
); } // localStorage key for timed mute timers const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; type MuteTimerEntry = { roomId: string; unmuteAt: number }; function loadMuteTimers(): MuteTimerEntry[] { try { return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); } catch { return []; } } function saveMuteTimers(timers: MuteTimerEntry[]): void { localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); } function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { const unmuteAt = Date.now() + durationMs; const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); saveMuteTimers([...existing, { roomId, unmuteAt }]); setTimeout(onUnmute, durationMs); } 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 [invitePrompt, setInvitePrompt] = useState(false); const [copiedLink, setCopiedLink] = useState(false); 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 = () => { markAsRead(mx, room.roomId, hideActivity); requestClose(); }; const handleCopyRoomLink = () => { const roomAlias = room.getCanonicalAlias() ?? room.roomId; const link = `https://matrix.to/#/${encodeURIComponent(roomAlias)}`; navigator.clipboard.writeText(link).catch(() => {}); setCopiedLink(true); setTimeout(() => setCopiedLink(false), 1500); }; const handleMuteFor = useCallback( async (durationMs: number | null) => { const { setRoomNotificationPreference } = await import('../../hooks/useRoomsNotificationPreferences'); const prevMode = notificationMode ?? RoomNotificationMode.Unset; await setRoomNotificationPreference( mx, room.roomId, RoomNotificationMode.Mute, prevMode, ).catch(() => {}); if (durationMs !== null) { scheduleMuteTimer(room.roomId, durationMs, () => { setRoomNotificationPreference( mx, room.roomId, RoomNotificationMode.Unset, RoomNotificationMode.Mute, ).catch(() => {}); saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== 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} > Mark as Read } radii="300" > {copiedLink ? 'Copied!' : 'Copy Link'} {(handleOpen, opened, changing) => ( ) : ( ) } radii="300" aria-pressed={opened} onClick={handleOpen} > Notifications )} {!isMuted && ( <> } radii="300" onClick={() => handleMuteFor(15 * 60 * 1000)} > Mute for 15m } radii="300" onClick={() => handleMuteFor(60 * 60 * 1000)} > Mute for 1h } radii="300" onClick={() => handleMuteFor(8 * 60 * 60 * 1000)} > Mute for 8h } radii="300" onClick={() => handleMuteFor(24 * 60 * 60 * 1000)} > Mute for 24h } radii="300" onClick={() => handleMuteFor(null)} > Mute indefinitely )} } radii="300" aria-pressed={isFavorite} > {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} } 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); const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId(), ); const roomName = useLocalRoomName(room); const hasLocalName = useHasLocalRoomName(room.roomId); const latestEvent = useRoomLatestRenderedEvent(room); const dmPreview = (() => { if (!direct || !latestEvent) return null; const type = latestEvent.getType(); const ts = latestEvent.getTs(); if (!ts) return null; // Skip pure membership events if (type === StateEvent.RoomMember) return null; let body: string; if (latestEvent.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: formatDmTimestamp(ts) }; })(); 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 startCall = useCallStart(direct); const callEmbed = useCallEmbed(); const callPref = useAtomValue(useCallPreferencesAtom()); const autoDiscoveryInfo = useAutoDiscoveryInfo(); 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; } // Start call in second click if (selected) { evt.preventDefault(); startCall(room, callPref); } }; return ( {showAvatar ? ( ( {nameInitials(roomName)} )} /> ) : ( )} {roomName} {hasLocalName && ( )} {isFavorite && ( )} {dmPreview && ( {dmPreview.preview} {dmPreview.time} )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( )} {!optionsVisible && unread && ( 0} count={unread.total} /> )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( )} {callMembers.length > 0 && ( {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_);