510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
|
import { Room, EventTimeline } from 'matrix-js-sdk';
|
|
import { useAtomValue } from 'jotai';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Text,
|
|
Menu,
|
|
MenuItem,
|
|
config,
|
|
PopOut,
|
|
toRem,
|
|
Line,
|
|
RectCords,
|
|
Badge,
|
|
Spinner,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
} from 'folds';
|
|
import { useFocusWithin, useHover } from 'react-aria';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
|
import * as css from '../../components/nav/styles.css';
|
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } 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 { usePowerLevels } from '../../hooks/usePowerLevels';
|
|
import { copyToClipboard } from '../../utils/dom';
|
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
|
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 { getMatrixToRoom } from '../../plugins/matrix-to';
|
|
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { getViaServers } from '../../plugins/via-servers';
|
|
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 { useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
|
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
|
|
import { useCall, useRoomCall } from '../call/useCall';
|
|
import { CallType } from '../call/types';
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
import { StateEvent } from '../../../types/matrix/room';
|
|
import { mDirectAtom } from '../../state/mDirectList';
|
|
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
|
|
|
type RoomNavItemMenuProps = {
|
|
room: Room;
|
|
requestClose: () => void;
|
|
notificationMode?: RoomNotificationMode;
|
|
};
|
|
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|
({ room, requestClose, 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 { startCall: startCallGlobal, endCall, activeCall } = useCall();
|
|
const { callSupported } = useRoomCall(room.roomId);
|
|
const markAsRead = useMarkAsRead(mx);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
|
const canAddSubRoom = permissions.stateEvent(StateEvent.PaarrotSubRooms, mx.getSafeUserId());
|
|
const openRoomSettings = useOpenRoomSettings();
|
|
const openCreateSubRoom = useOpenCreateSubRoomModal();
|
|
const space = useSpaceOptionally();
|
|
|
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
|
|
|
const handleMarkAsRead = () => {
|
|
markAsRead(room.roomId, hideActivity);
|
|
requestClose();
|
|
};
|
|
|
|
const handleInvite = () => {
|
|
setInvitePrompt(true);
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
|
requestClose();
|
|
};
|
|
|
|
const handleRoomSettings = () => {
|
|
openRoomSettings(room.roomId, space?.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const handleAddSubRoom = () => {
|
|
openCreateSubRoom(room.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const handleJoinVoice = async () => {
|
|
try {
|
|
// If already in a call, leave it first
|
|
if (activeCall) {
|
|
await endCall();
|
|
}
|
|
await startCallGlobal(room.roomId, CallType.Voice);
|
|
requestClose();
|
|
} catch (error) {
|
|
console.error('Failed to join voice call:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Menu ref={ref} style={{ maxWidth: toRem(160), 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>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<MenuItem
|
|
onClick={handleJoinVoice}
|
|
variant="Primary"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.VolumeHigh} />}
|
|
radii="300"
|
|
disabled={!callSupported}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Join Voice
|
|
</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={handleCopyLink}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Link} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Copy Link
|
|
</Text>
|
|
</MenuItem>
|
|
<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>
|
|
<MenuItem
|
|
onClick={handleAddSubRoom}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Plus} />}
|
|
radii="300"
|
|
disabled={!canAddSubRoom}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Add Sub-Room
|
|
</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={(evt) => {
|
|
if (evt.shiftKey) {
|
|
mx.leave(room.roomId);
|
|
requestClose();
|
|
} else {
|
|
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>
|
|
);
|
|
}
|
|
);
|
|
|
|
type RoomNavItemProps = {
|
|
room: Room;
|
|
selected: boolean;
|
|
linkPath: string;
|
|
notificationMode?: RoomNotificationMode;
|
|
showAvatar?: boolean;
|
|
direct?: boolean;
|
|
hideIcon?: boolean;
|
|
};
|
|
export function RoomNavItem({
|
|
room,
|
|
selected,
|
|
showAvatar,
|
|
direct,
|
|
notificationMode,
|
|
linkPath,
|
|
hideIcon,
|
|
}: RoomNavItemProps) {
|
|
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 unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
|
|
const avatarUrl = direct
|
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
|
|
|
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
|
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
|
(receipt) => receipt.userId !== mx.getUserId()
|
|
);
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
const mDirects = useAtomValue(mDirectAtom);
|
|
|
|
// Get first participated thread for preview
|
|
const firstThreadPreview = useMemo(() => {
|
|
try {
|
|
const threads = room.getThreads();
|
|
const participatedThreads = threads.filter((thread) => thread.hasCurrentUserParticipated);
|
|
if (participatedThreads.length === 0) return undefined;
|
|
|
|
const firstThread = participatedThreads[0];
|
|
const rootEvent = firstThread.rootEvent;
|
|
if (!rootEvent) return undefined;
|
|
|
|
const rootBody = rootEvent.getContent()?.body ?? '';
|
|
return rootBody;
|
|
} catch (error) {
|
|
return undefined;
|
|
}
|
|
}, [room]);
|
|
|
|
// Get parent space for DMs
|
|
const parentSpaceInfo = (() => {
|
|
if (!direct || !mDirects.has(room.roomId)) return undefined;
|
|
|
|
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
|
if (orphanParents.length === 0) return undefined;
|
|
|
|
const parentSpaceId = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
|
|
const parentSpace = mx.getRoom(parentSpaceId);
|
|
if (!parentSpace) return undefined;
|
|
|
|
const avatarEvent = parentSpace.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(StateEvent.RoomAvatar, '');
|
|
const avatarContent = avatarEvent?.getContent();
|
|
const avatarMxc = avatarContent && typeof avatarContent.url === 'string' ? avatarContent.url : undefined;
|
|
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
|
|
|
|
return {
|
|
name: parentSpace.name,
|
|
avatarUrl,
|
|
roomId: parentSpace.roomId,
|
|
};
|
|
})();
|
|
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;
|
|
|
|
return (
|
|
<Box direction="Column" style={{ width: '100%' }}>
|
|
<NavItem
|
|
className={!shouldShowIcon ? css.ThreadItem : undefined}
|
|
variant="Background"
|
|
radii="400"
|
|
highlight={unread !== undefined}
|
|
aria-selected={selected}
|
|
data-hover={!!menuAnchor}
|
|
onContextMenu={handleContextMenu}
|
|
{...hoverProps}
|
|
{...focusWithinProps}
|
|
>
|
|
<NavLink to={linkPath}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
|
{shouldShowIcon && (
|
|
<Avatar size="200" radii="400">
|
|
{showAvatar || avatarUrl ? (
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={avatarUrl}
|
|
alt={room.name}
|
|
renderFallback={() => (
|
|
<Text as="span" size="H6">
|
|
{nameInitials(room.name)}
|
|
</Text>
|
|
)}
|
|
/>
|
|
) : (
|
|
<RoomIcon
|
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
|
filled={selected}
|
|
size="100"
|
|
joinRule={room.getJoinRule()}
|
|
/>
|
|
)}
|
|
</Avatar>
|
|
)}
|
|
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
|
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
|
{room.name}
|
|
</Text>
|
|
{firstThreadPreview && (
|
|
<Text as="span" size="T200" priority="300" truncate>
|
|
╰ {firstThreadPreview}
|
|
</Text>
|
|
)}
|
|
</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)} />
|
|
)}
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavLink>
|
|
{optionsVisible && (
|
|
<NavItemOptions>
|
|
<Box alignItems="Center" gap="100">
|
|
{parentSpaceInfo && (
|
|
<TooltipProvider
|
|
position="Top"
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text size="T200">Space: {parentSpaceInfo.name}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<Avatar ref={triggerRef} size="200" radii="Pill" style={{ flexShrink: 0, width: '16px', height: '16px' }}>
|
|
<RoomAvatar
|
|
roomId={parentSpaceInfo.roomId}
|
|
src={parentSpaceInfo.avatarUrl}
|
|
alt={parentSpaceInfo.name}
|
|
renderFallback={() => (
|
|
<Text size="Inherit">
|
|
{nameInitials(parentSpaceInfo.name, 1)}
|
|
</Text>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
{optionsVisible && (
|
|
<PopOut
|
|
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)}
|
|
notificationMode={notificationMode}
|
|
/>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<IconButton
|
|
onClick={handleOpenMenu}
|
|
aria-pressed={!!menuAnchor}
|
|
variant="Background"
|
|
fill="None"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Icon size="50" src={Icons.VerticalDots} />
|
|
</IconButton>
|
|
</PopOut>
|
|
)}
|
|
</Box>
|
|
</NavItemOptions>
|
|
)}
|
|
</NavItem>
|
|
<CallParticipantsIndicator roomId={room.roomId} />
|
|
</Box>
|
|
);
|
|
}
|