284 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|