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:
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
setCancelErrors((prev) => {
|
||||
if (!prev.has(msg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await cancelScheduledMessage(mx, msg.delayId);
|
||||
} catch {
|
||||
// If cancellation fails on the server, still remove locally
|
||||
// since the user intends to remove it
|
||||
} finally {
|
||||
// Only prune local state once the server confirms cancellation. If we
|
||||
// removed it optimistically the still-live delayed event would fire and
|
||||
// the "cancelled" message would send anyway.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep the item (still cancellable) and surface an inline error; the
|
||||
// delayed event is still scheduled on the server.
|
||||
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
|
||||
} finally {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
direction="Column"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string'
|
||||
? (msg.content.body as string)
|
||||
: '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{cancelErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user