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 ( } title={ {t('Direct.no_direct_messages')} } content={ {t('Direct.no_direct_messages_desc')} } options={ } /> ); } type SpamToggleRowProps = { spamCount: number; expanded: boolean; onToggle: () => void; }; function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) { const { t } = useTranslation(); return ( {expanded ? t('Direct.invite_hide_spam') : t('Direct.invite_show_spam', { count: spamCount })} ); } export function Direct() { const mx = useMatrixClient(); useNavToActivePathMapper('direct'); const scrollRef = useRef(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(() => { 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 ( {/* 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. */} }> {noRoomToDisplay ? ( ) : (
{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 ( ); } if (item.kind === 'spam-toggle') { return ( setSpamExpanded((v) => !v)} /> ); } const { roomId } = item; const room = mx.getRoom(roomId); if (!room) return null; const selected = selectedRoomId === roomId; return ( ); })}
)}
); }