diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index a46e01113..d6f5021f0 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Box, Line } from 'folds'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; @@ -49,15 +49,46 @@ export function Room() { useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { + // Skip when a composer already consumed Escape (it preventDefaults). + if (evt.defaultPrevented) return; + // Skip while a thread panel is open: listener registration order + // means this can run BEFORE the panel's own Escape handler, and the + // user's intent there is "close the panel", not "mark room read". + if (activeThreadId) return; markAsRead(mx, room.roomId, hideActivity); } }, - [mx, room.roomId, hideActivity], + [mx, room.roomId, hideActivity, activeThreadId], ), ); const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0; + // Thread panel and media gallery are mutually exclusive on every screen size: + // opening one closes the other. Detect the just-opened transition so whichever + // was opened most recently wins. + const prevThreadRef = useRef(activeThreadId); + const prevGalleryRef = useRef(galleryOpen); + useEffect(() => { + const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current; + const galleryJustOpened = galleryOpen && !prevGalleryRef.current; + if (threadJustOpened && galleryOpen) { + setGalleryOpen(false); + } else if (galleryJustOpened && activeThreadId) { + setActiveThreadId(null); + } + prevThreadRef.current = activeThreadId; + prevGalleryRef.current = galleryOpen; + }, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]); + + // On non-desktop screens at most one right-side panel may show, priority + // thread > gallery > members. On desktop thread + members may coexist while + // thread + gallery stay mutually exclusive (via the effect above). + const isDesktop = screenSize === ScreenSize.Desktop; + const showThreadPanel = !callView && Boolean(activeThreadId); + const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId); + const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen)); + return ( @@ -86,7 +117,7 @@ export function Room() { )} - {!callView && galleryOpen && ( + {showGallery && ( <> {screenSize === ScreenSize.Desktop && ( @@ -94,7 +125,7 @@ export function Room() { setGalleryOpen(false)} /> )} - {!callView && activeThreadId && ( + {showThreadPanel && activeThreadId && ( <> {screenSize === ScreenSize.Desktop && ( @@ -107,7 +138,7 @@ export function Room() { /> )} - {!callView && isDrawer && ( + {showMembers && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 08f27cf76..5d5fbae7a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -679,15 +679,23 @@ export const RoomInput = forwardRef( submit(); } if (isKeyHotkey('escape', evt)) { - evt.preventDefault(); + // Only consume Escape (and stop it bubbling to the thread panel / room + // window handlers) when the composer actually has something to dismiss. + // If we did nothing, let Escape propagate so those handlers can run. if (autocompleteQuery) { + evt.preventDefault(); + evt.stopPropagation(); setAutocompleteQuery(undefined); return; } - setReplyDraft(undefined); + if (replyDraft) { + evt.preventDefault(); + evt.stopPropagation(); + setReplyDraft(undefined); + } } }, - [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing], + [submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing], ); const handleKeyUp: KeyboardEventHandler = useCallback( diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 4534beba5..d00c4a85e 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom); const [expanded, setExpanded] = useState(false); const [cancelling, setCancelling] = useState>(new Set()); + const [cancelErrors, setCancelErrors] = useState>(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) => ( - - {typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'} - - - {formatSendAt(msg.sendAt)} - - { - e.stopPropagation(); - handleCancel(msg); - }} - > - - + + + {typeof msg.content.body === 'string' + ? (msg.content.body as string) + : '(message)'} + + + {formatSendAt(msg.sendAt)} + + { + e.stopPropagation(); + handleCancel(msg); + }} + > + + + + {cancelErrors.has(msg.delayId) && ( + + Could not cancel this message. Try again. + + )} ))} diff --git a/src/app/features/room/message/RemindMeDialog.tsx b/src/app/features/room/message/RemindMeDialog.tsx index 6fe6e8df5..edfa31cd2 100644 --- a/src/app/features/room/message/RemindMeDialog.tsx +++ b/src/app/features/room/message/RemindMeDialog.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Box, Button, + color, config, Dialog, Header, @@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind const modalStyle = useModalStyle(320); const { addReminder } = useReminders(); const presets = useMemo(() => getPresets(), []); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); const handlePick = async (ms: number) => { - await addReminder({ - roomId, - eventId, - timestamp: Date.now() + ms, - message: previewText || 'Reminder', - }); - onClose(); + if (busy) return; + setBusy(true); + setError(null); + try { + await addReminder({ + roomId, + eventId, + timestamp: Date.now() + ms, + message: previewText || 'Reminder', + }); + onClose(); + } catch { + setBusy(false); + setError('Could not set reminder. Try again.'); + } }; return ( @@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind variant="Secondary" fill="Soft" radii="300" + disabled={busy} onClick={() => handlePick(p.ms)} > @@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind ))} + {error && ( + + {error} + + )} diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx index 42ca666a9..8455a723d 100644 --- a/src/app/features/room/thread/ThreadPanel.tsx +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -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(); diff --git a/src/app/features/room/thread/ThreadTimeline.css.ts b/src/app/features/room/thread/ThreadTimeline.css.ts index 303afb3a2..6e9696e21 100644 --- a/src/app/features/room/thread/ThreadTimeline.css.ts +++ b/src/app/features/room/thread/ThreadTimeline.css.ts @@ -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, diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 90ba88ea3..04699d875 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -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) { + {!atBottom && ( + + } + onClick={handleJumpToBottom} + > + Jump to Latest + + + )} {editHistoryEvent && (