Files
cinny/src/app/features/room-nav/RoomNavItem.tsx

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