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

195 lines
6.7 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, 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 { DmStreamRow } from '../../../features/room-nav';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { PageNav, PageNavContent } from '../../../components/page';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { DirectStreamHeader } from './DirectStreamHeader';
import { DirectNewChatRow } from './DirectNewChatRow';
import { DirectSelfRow } from './DirectSelfRow';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
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>
);
}
function DirectFooterStatus() {
const { t } = useTranslation();
return (
<Box
alignItems="Center"
gap="200"
style={{
padding: `${toRem(8)} ${toRem(14)}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
fontFamily: MONO_FONT,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.45,
}}
>
<span
style={{
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
background: color.Success.Main,
display: 'inline-block',
}}
aria-hidden
/>
<span>vojo.chat</span>
<Box grow="Yes" />
<span>{t('Direct.status_e2ee')}</span>
</Box>
);
}
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
// 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 noRoomToDisplay = directs.length === 0;
// Sort each render — small list, getLastActiveTimestamp changes outside
// React's dep model so memoising would need a manual trigger anyway.
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 68,
overscan: 10,
});
return (
<PageNav size="500">
<DirectStreamHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
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 />
<DirectFooterStatus />
</PageNav>
);
}