vojo/src/app/features/room-nav/DmStreamRow.tsx

498 lines
18 KiB
TypeScript

import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk/lib/models/room';
import {
Avatar,
Badge,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
config,
toRem,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, isOneOnOneRoom } 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 { 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 { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } 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 {
RoomNotificationMode,
getRoomNotificationModeIcon,
} 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 { useRoomName } 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 { timeHourMinute } from '../../utils/time';
import * as css from './styles.css';
type DmStreamRowMenuProps = {
room: Room;
requestClose: () => void;
notificationMode?: RoomNotificationMode;
};
const DmStreamRowMenu = forwardRef<HTMLDivElement, DmStreamRowMenuProps>(
({ room, requestClose, notificationMode }, ref) => {
const { t } = useTranslation();
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 handleMarkAsRead = () => {
markAsRead(mx, 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();
};
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>
{t('Home.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>
{t('Home.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<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>
{t('Home.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>
{t('Home.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>
{t('Home.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>
{t('Home.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
function CallChatToggle() {
const { t } = useTranslation();
const [chat, setChat] = useAtom(callChatAtom);
return (
<IconButton
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label={t('Home.toggle_chat')}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.Message} filled={chat} />
</IconButton>
);
}
type RoomPreview = { ts: number | undefined; text: string };
const deriveRoomPreview = (room: Room): RoomPreview => {
const events = room.getLiveTimeline().getEvents();
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i];
if (ev.getType() === 'm.room.message' && !ev.isRedacted()) {
const content = ev.getContent();
const body = typeof content?.body === 'string' ? content.body.trim() : '';
if (body) {
return { ts: ev.getTs(), text: body.replace(/\s+/g, ' ') };
}
}
}
return { ts: room.getLastActiveTimestamp() || undefined, text: '' };
};
const formatRowTime = (ts: number): string => {
const now = Date.now();
const diff = now - ts;
const day = 24 * 60 * 60 * 1000;
const date = new Date(ts);
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (sameDay) return timeHourMinute(ts);
if (diff < 7 * day) {
return date.toLocaleDateString(undefined, { weekday: 'short' });
}
return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
};
type DmStreamRowProps = {
room: Room;
selected: boolean;
linkPath: string;
notificationMode?: RoomNotificationMode;
};
export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmStreamRowProps) {
const { t } = useTranslation();
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 typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const roomName = useRoomName(room);
const preview = deriveRoomPreview(room);
const previewText = preview.text;
const previewTs = preview.ts;
const timeLabel = previewTs ? formatRowTime(previewTs) : '';
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(room, callSession);
const startCall = useCallStart(true);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
return;
}
if (callEmbed) {
return;
}
if (selected) {
evt.preventDefault();
startCall(room, callPref);
}
};
return (
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
className={css.DmRowOuter}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
className={css.DmRowInner}
>
<Avatar
size="500"
radii="500"
style={{ width: toRem(48), height: toRem(48) }}
>
<RoomAvatar
roomId={room.roomId}
src={
isOneOnOneRoom(room)
? // 1:1 — fall back to the peer's avatar when the room
// itself has no custom one. Mirrors RoomViewHeader's
// `useRoomAvatar(room, isOneOnOne)` semantics.
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: // Group / non-1:1 — use the room avatar only. Without
// this guard `getDirectRoomAvatarUrl` would surface the
// first non-self member's avatar in groups without a
// custom room avatar, making them look like 1:1 chats.
getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H5">
{nameInitials(roomName)}
</Text>
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
className={css.DmRowText}
>
<Box as="span" alignItems="Baseline" gap="200">
<Box as="span" grow="Yes" className={css.DmRowTextStretch}>
<Text
as="span"
priority={unread ? '500' : '400'}
size="T300"
truncate
className={css.DmRowName}
>
{roomName}
</Text>
</Box>
{timeLabel && !optionsVisible && (
<span className={css.DmRowTime}>{timeLabel}</span>
)}
</Box>
<Box as="span" alignItems="Center" gap="200">
<Box as="span" grow="Yes" className={css.DmRowTextStretch}>
{typingMember.length > 0 ? (
<TypingIndicator size="300" disableAnimation />
) : (
<Text
as="span"
size="T200"
truncate
className={unread ? css.DmRowPreviewUnread : css.DmRowPreview}
>
{previewText}
</Text>
)}
</Box>
{!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}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{t('Home.live_count', { count: callMembers.length })}
</Text>
</Badge>
)}
</Box>
</Box>
</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,
}}
>
<DmStreamRowMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
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>
)}
</NavItem>
);
}