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

284 lines
10 KiB
TypeScript

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 { DirectStreamHeader } from './DirectStreamHeader';
import { DirectNewChatRow } from './DirectNewChatRow';
import { DirectSelfRow } from './DirectSelfRow';
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',
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/).
const [, setActivityTick] = useState(0);
useEffect(() => {
const directsSet = new Set(directs);
const handleTimeline = (
_ev: unknown,
room: { roomId: string } | undefined,
_start: unknown,
_removed: unknown,
data: { liveEvent?: boolean } | undefined
) => {
if (!room || !data?.liveEvent) return;
if (!directsSet.has(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, directs]);
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. 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>
<DirectStreamHeader />
{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>
);
}
// kind === 'direct'
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>
)}
<DirectNewChatRow />
<DirectSelfRow />
</PageNav>
);
}