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>
This commit is contained in:
2026-07-02 00:18:51 -04:00
parent 7f960b026b
commit 664dcd4cd8
7 changed files with 166 additions and 51 deletions
@@ -123,6 +123,10 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
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();
@@ -11,6 +11,15 @@ export const ThreadTimelineContent = style({
padding: `${config.space.S400} 0`,
});
export const ThreadTimelineFloat = style({
position: 'absolute',
bottom: config.space.S400,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
minWidth: 'max-content',
});
export const ThreadCentered = style({
height: '100%',
padding: config.space.S700,
@@ -29,7 +29,7 @@ import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
@@ -459,6 +459,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
}
}, [scrollToBottomCount]);
const handleJumpToBottom = useCallback(() => {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
// Flip atBottom so the layout effect re-runs (count re-read) and live
// events resume sticking to the bottom.
setAtBottom(true);
}, []);
// Scroll in-place editor into view.
useEffect(() => {
if (editId) {
@@ -949,6 +957,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToBottom}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
)}
{editHistoryEvent && (
<EditHistoryModal
room={room}