287 lines
11 KiB
TypeScript
287 lines
11 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 { 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>
|
||
);
|
||
}
|