import React, { useCallback, useEffect, useRef } from 'react'; import { Box, Header, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider, } from 'folds'; import { Room, RoomEvent, ThreadEvent } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import classNames from 'classnames'; import * as css from './ThreadPanel.css'; import { ContainerColor } from '../../../styles/ContainerColor.css'; import { ThreadTimeline } from './ThreadTimeline'; import { markThreadAsRead, useThreadInstance } from './useThread'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useEditor } from '../../../components/editor'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { RoomInput } from '../RoomInput'; import { getThreadNotificationModeIcon, ThreadNotificationModeSwitcher, } from '../../../components/ThreadNotificationModeSwitcher'; import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications'; import { ThreadNotificationMode } from '../../../utils/threadNotifications'; type ThreadPanelHeaderProps = { room: Room; threadId: string; requestClose: () => void; }; function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) { const mode = useThreadNotificationMode(room.roomId, threadId); return (
Thread {room.name} {(handleOpen, opened) => ( Notifications } > {(triggerRef) => ( )} )} Close } > {(triggerRef) => ( )}
); } export type ThreadPanelProps = { room: Room; threadId: string; requestClose: () => void; }; export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) { const mx = useMatrixClient(); const editor = useEditor(); const thread = useThreadInstance(room, threadId); const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts'); const fileDropContainerRef = useRef(null) as React.RefObject; useKeyDown( window, useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { // The composer preventDefaults Escape when it consumes it (dismissing // autocomplete / clearing a reply draft). Don't close the panel in // that case — only when Escape wasn't already handled. if (evt.defaultPrevented) return; evt.preventDefault(); evt.stopPropagation(); requestClose(); } }, [requestClose], ), ); // Mark the thread read when the panel is open and on each new thread event. // Deduped on the latest event id: RoomEvent.Timeline re-emits per event during // backfill and for every edit/reaction, and sendReadReceipt POSTs // unconditionally — without the guard, opening a thread with N replies would // fire up to N receipt requests at the same event. const lastReadEventIdRef = useRef(undefined); useEffect(() => { lastReadEventIdRef.current = undefined; if (!thread) return undefined; const markRead = () => { const events = thread.liveTimeline.getEvents(); let latestId: string | undefined; for (let i = events.length - 1; i >= 0; i -= 1) { const evt = events[i]; if (evt && !evt.isSending()) { latestId = evt.getId() ?? undefined; break; } } if (!latestId || latestId === lastReadEventIdRef.current) return; lastReadEventIdRef.current = latestId; markThreadAsRead(mx, thread, privateReadReceipts).catch(() => { // Allow a retry on the next event if the receipt POST failed. if (lastReadEventIdRef.current === latestId) { lastReadEventIdRef.current = undefined; } }); }; markRead(); thread.on(ThreadEvent.NewReply, markRead); thread.on(RoomEvent.Timeline, markRead); return () => { thread.off(ThreadEvent.NewReply, markRead); thread.off(RoomEvent.Timeline, markRead); }; }, [mx, thread, privateReadReceipts]); return ( {!thread ? ( Loading thread… ) : ( <> )} ); }