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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user