vojo/src/app/pages/client/direct/Direct.tsx

287 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, toRem } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
import { DirectInviteRow, DmStreamRow } from '../../../features/room-nav';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
import { PageNav, PageNavContent } from '../../../components/page';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { StreamHeader } from '../../../components/stream-header';
import { DirectSelfRow } from './DirectSelfRow';
import { MobileSettingsHorseshoe } from '../../../features/settings';
type ListItem =
| { kind: 'invite'; entry: DirectInviteEntry }
| { kind: 'spam-toggle'; spamCount: number; expanded: boolean }
| { kind: 'spam-invite'; entry: DirectInviteEntry }
| { kind: 'direct'; roomId: string };
function DirectEmpty() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
{t('Direct.no_direct_messages')}
</Text>
}
content={
<Text size="T300" align="Center">
{t('Direct.no_direct_messages_desc')}
</Text>
}
options={
<Button variant="Primary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
{t('Direct.start_first_chat')}
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
type SpamToggleRowProps = {
spamCount: number;
expanded: boolean;
onToggle: () => void;
};
function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
const { t } = useTranslation();
return (
<Box
as="button"
type="button"
onClick={onToggle}
alignItems="Center"
gap="200"
aria-expanded={expanded}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: `${toRem(8)} ${toRem(12)}`,
width: '100%',
color: color.Surface.OnContainer,
opacity: 0.7,
textAlign: 'left',
font: 'inherit',
}}
>
<Icon size="100" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
<Text as="span" size="T200" style={{ fontWeight: 500 }}>
{expanded
? t('Direct.invite_hide_spam')
: t('Direct.invite_show_spam', { count: spamCount })}
</Text>
</Box>
);
}
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const invites = useDirectInvites();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const [spamExpanded, setSpamExpanded] = useState(false);
// roomToUnreadAtom only changes on read/unread transitions and ignores own
// events — covers incoming notifying messages but not own sends or muted
// incoming. Kept as a subscribe-only re-render trigger.
useAtomValue(roomToUnreadAtom);
// Activity tick: bump on every live timeline event in any DM room so list
// order, preview text and time labels refresh on own sends, muted incoming,
// reactions — anything roomToUnread misses. Uses the 'Room.timeline'
// literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk
// that the project's moduleResolution flags as TS2614 (see
// docs/known-tech-debt-lint/). `directs` is read via a ref so the listener
// doesn't re-bind on every set-identity change (heavy sessions can flip
// the set 5-10× during sync).
const [, setActivityTick] = useState(0);
const directsRef = useRef(directs);
directsRef.current = directs;
useEffect(() => {
const handleTimeline = (
_ev: unknown,
room: { roomId: string } | undefined,
_start: unknown,
_removed: unknown,
data: { liveEvent?: boolean } | undefined
) => {
if (!room || !data?.liveEvent) return;
if (!directsRef.current.includes(room.roomId)) return;
setActivityTick((n) => n + 1);
};
const emitter = mx as unknown as {
on: (e: string, h: typeof handleTimeline) => void;
removeListener: (e: string, h: typeof handleTimeline) => void;
};
emitter.on('Room.timeline', handleTimeline);
return () => {
emitter.removeListener('Room.timeline', handleTimeline);
};
}, [mx]);
const selectedRoomId = useSelectedRoom();
const items = useMemo<ListItem[]>(() => {
const list: ListItem[] = [];
const cleanInvites = invites.filter((i) => !i.isSpam);
const spamInvites = invites.filter((i) => i.isSpam);
cleanInvites.forEach((entry) => list.push({ kind: 'invite', entry }));
if (spamInvites.length > 0) {
list.push({
kind: 'spam-toggle',
spamCount: spamInvites.length,
expanded: spamExpanded,
});
if (spamExpanded) {
spamInvites.forEach((entry) => list.push({ kind: 'spam-invite', entry }));
}
}
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
sortedDirects.forEach((roomId) => list.push({ kind: 'direct', roomId }));
return list;
}, [invites, directs, spamExpanded, mx]);
const noRoomToDisplay = items.length === 0;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
// Per-kind estimates so the initial scroll height is close to actual
// before measureElement self-corrects: invite cards are ~140px (header +
// sub-line + buttons + optional topic), spam toggle is ~32px, DM rows
// ~68px (avatar 48px + 2-line title block + padding). Keeps first paint
// stable when the panel opens with invites.
estimateSize: (index) => {
const item = items[index];
if (!item) return 68;
if (item.kind === 'invite' || item.kind === 'spam-invite') return 140;
if (item.kind === 'spam-toggle') return 32;
return 68;
},
// Stable per-item identity so the measurement cache survives item-kind
// shifts at the same index. Without this TanStack falls back to index,
// and a DM row at index 0 inheriting an invite card's measured height
// (or vice versa) flashes a wrong size for one frame on every list
// mutation that adds/removes an invite at the top.
getItemKey: (index) => {
const item = items[index];
if (!item) return index;
if (item.kind === 'invite') return `invite:${item.entry.roomId}`;
if (item.kind === 'spam-invite') return `spam-invite:${item.entry.roomId}`;
if (item.kind === 'spam-toggle') return 'spam-toggle';
return `direct:${item.roomId}`;
},
overscan: 10,
});
return (
<PageNav resizable surface="surfaceVariant">
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
Settings sheet can carve into the bottom of this pane. On non-mobile
it's a pass-through. */}
<MobileSettingsHorseshoe>
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}>
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((vItem) => {
const item = items[vItem.index];
if (!item) return null;
if (item.kind === 'invite' || item.kind === 'spam-invite') {
const { entry } = item;
const selected = selectedRoomId === entry.roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`invite-${entry.roomId}`}
ref={virtualizer.measureElement}
>
<DirectInviteRow
room={entry.room}
selected={selected}
isSpam={item.kind === 'spam-invite'}
/>
</VirtualTile>
);
}
if (item.kind === 'spam-toggle') {
return (
<VirtualTile
virtualItem={vItem}
key="spam-toggle"
ref={virtualizer.measureElement}
>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`direct-${roomId}`}
ref={virtualizer.measureElement}
>
<DmStreamRow
room={room}
selected={selected}
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</StreamHeader>
</MobileSettingsHorseshoe>
</PageNav>
);
}