Files
cinny/src/app/features/room/thread/ThreadPanel.tsx
T
jared 664dcd4cd8 fix(audit): correctness wave — ghost sends, Escape coordination, panel exclusion
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
  cancel; failures keep the item + show an inline error (was: a failed cancel
  looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
  iff autocomplete is open or a reply draft is set; the thread panel and Room's
  markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
  a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
  opened wins); on mobile at most one right panel renders (thread > gallery >
  members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
  try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
  idiom).

From the 4-auditor deep-audit wave; reviewer-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:18:51 -04:00

207 lines
6.9 KiB
TypeScript

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 (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" direction="Column">
<Text size="H5" truncate>
Thread
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
{(handleOpen, opened) => (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Notifications</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Thread notifications"
aria-pressed={opened}
onClick={handleOpen}
>
<Icon
src={getThreadNotificationModeIcon(mode)}
filled={mode !== ThreadNotificationMode.Default}
/>
</IconButton>
)}
</TooltipProvider>
)}
</ThreadNotificationModeSwitcher>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close thread"
onClick={requestClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
);
}
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<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
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<string | undefined>(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 (
<Box
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
{!thread ? (
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Spinner size="400" variant="Secondary" />
<Text size="T300">Loading thread</Text>
</Box>
) : (
<>
<Box grow="Yes" className={css.ThreadPanelContent} direction="Column">
<ThreadTimeline room={room} thread={thread} editor={editor} />
</Box>
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
<RoomInput
room={room}
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
fileDropContainerRef={fileDropContainerRef}
/>
</Box>
</>
)}
</Box>
);
}