c54cb126ff
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560), forcing maxWidth 560px and squishing the two-pane Settings layout (folds size="500" is ~50rem). Restore desktop width to the folds recipe while keeping mobile fullscreen. N-series fixes: - N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button> - N28 composer char counter: drop undefined --tc-surface-low + opacity, use priority="300" and config.space token - N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button pattern matching (edited) link - N41 UserPrivateNotes "Saving..." now shows a folds <Spinner> - N43 Night Light slider: add accentColor; label opacity -> priority - N44 mention-highlight Reset: bare <button> -> folds <Button> (drops undefined --border-interactive-normal); Boot button kept (TDS-only) - N45 SelectTheme trigger variant -> Secondary to match SettingsSelect - N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/ Headphone/File) - N54/N57 PiP overlay badges + fullscreen button: token discipline (config.radii/space, folds Text); dark scrim kept for video legibility - N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem insets, no hardcoded size overrides) - N62 unverified-device banner: 3px left-accent -> standard border via color.Warning.ContainerLine; drop opacity hacks - N65 Edit History: real "Load more" pagination (accumulate next_batch, de-dupe by id, re-sort by ts) replacing passive text - N66 search date fields: raw <input type="date"> -> folds <Input> - N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals) - N73 Pending Requests header uses css.MembersGroupLabel - N74 remove raw em-sized emoji <span> in RoomNavItem name - N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA) Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
844 lines
29 KiB
TypeScript
844 lines
29 KiB
TypeScript
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';
|
|
import { EmojiBoard } from '../../components/emoji-board';
|
|
|
|
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<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;
|
|
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<HTMLInputElement>) => {
|
|
if (evt.key === 'Enter') handleSave();
|
|
if (evt.key === 'Escape') onClose();
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: onClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog 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>
|
|
);
|
|
}
|
|
|
|
// 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<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 [invitePrompt, setInvitePrompt] = useState(false);
|
|
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
|
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 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 (
|
|
<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}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Mark as Read
|
|
</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={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);
|
|
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<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 startCall = useCallStart(direct);
|
|
const callEmbed = useCallEmbed();
|
|
const callPref = useAtomValue(useCallPreferencesAtom());
|
|
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
|
|
|
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;
|
|
}
|
|
// Start call in second click
|
|
if (selected) {
|
|
evt.preventDefault();
|
|
startCall(room, callPref);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<NavItem
|
|
variant="Background"
|
|
radii="400"
|
|
highlight={unread !== undefined}
|
|
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: unread ? 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={unread ? '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 }}
|
|
/>
|
|
)}
|
|
</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 && !unread && !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 && notificationMode !== RoomNotificationMode.Unset && (
|
|
<Icon
|
|
size="50"
|
|
src={getRoomNotificationModeIcon(notificationMode)}
|
|
aria-label={notificationMode}
|
|
/>
|
|
)}
|
|
{callMembers.length > 0 && (
|
|
<Badge variant="Critical" fill="Solid" size="400">
|
|
<Text as="span" size="L400" truncate>
|
|
{callMembers.length} Live
|
|
</Text>
|
|
</Badge>
|
|
)}
|
|
</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,
|
|
returnFocusOnDeactivate: 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_);
|