From 7e22d86a5105fe5bb1ca1f7d05798dd2e0d3ec28 Mon Sep 17 00:00:00 2001 From: jasonlaguidice <19523621+jasonlaguidice@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:41:05 -0800 Subject: [PATCH 1/2] feat: port MSC4140 scheduled messages from cinny#2671 --- src/app/features/room/RoomInput.tsx | 272 ++++++++++++++---- src/app/features/room/RoomView.tsx | 22 +- .../schedule-send/SchedulePickerDialog.css.ts | 18 ++ .../schedule-send/SchedulePickerDialog.tsx | 270 +++++++++++++++++ .../ScheduledMessagesList.css.ts | 32 +++ .../schedule-send/ScheduledMessagesList.tsx | 182 ++++++++++++ src/app/features/room/schedule-send/index.ts | 2 + src/app/hooks/useDelayedEventsSupport.ts | 20 ++ src/app/state/scheduledMessages.ts | 15 + src/app/utils/delayedEvents.ts | 114 ++++++++ 10 files changed, 892 insertions(+), 55 deletions(-) create mode 100644 src/app/features/room/schedule-send/SchedulePickerDialog.css.ts create mode 100644 src/app/features/room/schedule-send/SchedulePickerDialog.tsx create mode 100644 src/app/features/room/schedule-send/ScheduledMessagesList.css.ts create mode 100644 src/app/features/room/schedule-send/ScheduledMessagesList.tsx create mode 100644 src/app/features/room/schedule-send/index.ts create mode 100644 src/app/hooks/useDelayedEventsSupport.ts create mode 100644 src/app/state/scheduledMessages.ts create mode 100644 src/app/utils/delayedEvents.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8aaf042c98..dfdbb8859a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -30,10 +30,13 @@ import { IconButton, Icons, Line, + Menu, + MenuItem, Overlay, OverlayBackdrop, OverlayCenter, PopOut, + RectCords, Scroll, Text, toRem, @@ -130,6 +133,23 @@ import { getImageMsgContent, getVideoMsgContent, } from './msgContent'; +import FocusTrap from 'focus-trap-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + delayedEventsSupportedAtom, + roomIdToScheduledTimeAtomFamily, + roomIdToEditingScheduledDelayIdAtomFamily, +} from '$state/scheduledMessages'; +import { + sendDelayedMessage, + sendDelayedMessageE2EE, + computeDelayMs, + cancelDelayedEvent, +} from '$utils/delayedEvents'; +import { SchedulePickerDialog } from './schedule-send'; +import * as css from './schedule-send/SchedulePickerDialog.css'; +import { timeHourMinute, timeDayMonthYear } from '$utils/time'; +import { stopPropagation } from '$utils/keyboard'; const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; @@ -242,6 +262,17 @@ export const RoomInput = forwardRef( const isComposing = useComposingCheck(); + const queryClient = useQueryClient(); + const delayedEventsSupported = useAtomValue(delayedEventsSupportedAtom); + const [scheduledTime, setScheduledTime] = useAtom(roomIdToScheduledTimeAtomFamily(roomId)); + const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( + roomIdToEditingScheduledDelayIdAtomFamily(roomId) + ); + const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); + const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const isEncrypted = room.hasEncryptionStateEvent(); + useElementSizeObserver( useCallback(() => document.body, []), useCallback((width) => setHideStickerBtn(width < 500), []) @@ -428,21 +459,54 @@ export const RoomInput = forwardRef( if (replyDraft) { content['m.relates_to'] = getReplyContent(replyDraft); } - if (replyDraft) { - setReplyDraft(undefined); - } + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); - resetEditor(editor); - resetEditorHistory(editor); - setInputKey((prev) => prev + 1); - sendTypingStatus(false); + const resetInput = () => { + resetEditor(editor); + resetEditorHistory(editor); + setInputKey((prev) => prev + 1); + setReplyDraft(undefined); + sendTypingStatus(false); + }; - try { - await mx.sendMessage(roomId, content as any); - } catch (error) { - log.error('failed to send message', { roomId }, error); + if (scheduledTime) { + try { + const delayMs = computeDelayMs(scheduledTime); + if (editingScheduledDelayId) { + await cancelDelayedEvent(mx, editingScheduledDelayId); + } + if (isEncrypted) { + await sendDelayedMessageE2EE(mx, roomId, room, content, delayMs); + } else { + await sendDelayedMessage(mx, roomId, content, delayMs); + } + invalidate(); + setEditingScheduledDelayId(null); + setScheduledTime(null); + resetInput(); + } catch { + // Network/server error — leave editor and scheduled state intact for retry + } + } else if (editingScheduledDelayId) { + try { + await cancelDelayedEvent(mx, editingScheduledDelayId); + mx.sendMessage(roomId, content as any); + invalidate(); + setEditingScheduledDelayId(null); + resetInput(); + } catch { + // Cancel failed — leave state intact for retry + } + } else { + try { + await mx.sendMessage(roomId, content as any); + } catch (error) { + log.error('failed to send message', { roomId }, error); + } + resetInput(); } - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); + }, [mx, roomId, room, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, scheduledTime, setScheduledTime, isEncrypted, queryClient, editingScheduledDelayId, setEditingScheduledDelayId]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -617,43 +681,73 @@ export const RoomInput = forwardRef( onKeyUp={handleKeyUp} onPaste={handlePaste} top={ - replyDraft && ( -
- - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" + <> + {scheduledTime && ( +
+ - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId, nicknames) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } + { + setScheduledTime(null); + setEditingScheduledDelayId(null); + }} + variant="SurfaceVariant" + size="300" + radii="300" > - - {replyBodyJSX} + + + + + + Scheduled for {timeDayMonthYear(scheduledTime.getTime())} at{' '} + {timeHourMinute(scheduledTime.getTime(), hour24Clock)} - + - -
- ) +
+ )} + {replyDraft && ( +
+ + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId, nicknames) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > + + {replyBodyJSX} + + + + +
+ )} + } before={ ( )} - e.preventDefault()} - variant="SurfaceVariant" - size="300" - radii="300" - > - - + setScheduleMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + { + setScheduleMenuAnchor(undefined); + submit(); + }} + before={} + > + Send Now + + { + setScheduleMenuAnchor(undefined); + setShowSchedulePicker(true); + }} + before={} + > + Schedule Send + + + + + } + /> + + e.preventDefault()} + variant={scheduledTime ? 'Primary' : 'SurfaceVariant'} + size="300" + radii="0" + className={delayedEventsSupported ? css.SplitSendButton : undefined} + > + + + {delayedEventsSupported && ( + ) => { + setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }} + variant={scheduledTime ? 'Primary' : 'SurfaceVariant'} + size="300" + radii="0" + className={css.SplitChevronButton} + > + + + )} + } bottom={ @@ -762,6 +915,17 @@ export const RoomInput = forwardRef( ) } /> + {showSchedulePicker && ( + setShowSchedulePicker(false)} + onSubmit={(date) => { + setScheduledTime(date); + setShowSchedulePicker(false); + }} + /> + )} ); } diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 80d6d04ee3..bf825699c1 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,4 +1,6 @@ import { useCallback, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { Transforms } from 'slate'; import { Box, Text, config, toRem } from 'folds'; import { EventType, Room } from '$types/matrix-sdk'; import { ReactEditor } from 'slate-react'; @@ -7,7 +9,7 @@ import { useStateEvent } from '$hooks/useStateEvent'; import { StateEvent } from '$types/matrix/room'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useEditor } from '$components/editor'; +import { useEditor, resetEditor } from '$components/editor'; import { Page } from '$components/page'; import { useKeyDown } from '$hooks/useKeyDown'; import { editableActiveElement } from '$utils/dom'; @@ -28,6 +30,9 @@ import { RoomTombstone } from './RoomTombstone'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTimeline } from './RoomTimeline'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; +import { ScheduledMessagesList } from './schedule-send'; +import { useDelayedEventsSupport } from '$hooks/useDelayedEventsSupport'; +import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -82,6 +87,18 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const [editorResetKey, setEditorResetKey] = useState(0); const handleResetEditor = useCallback(() => setEditorResetKey((prev) => prev + 1), []); + useDelayedEventsSupport(); + const delayedEventsSupported = useAtomValue(delayedEventsSupportedAtom); + + const handleEditMessage = useCallback( + (body: string) => { + resetEditor(editor); + if (body) Transforms.insertText(editor, body); + ReactEditor.focus(editor); + }, + [editor] + ); + useKeyDown( window, useCallback( @@ -133,6 +150,9 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { + {canMessage && delayedEventsSupported && ( + + )}
{tombstoneEvent ? ( void; + onSubmit: (scheduledDate: Date) => void; +}; + +export function SchedulePickerDialog({ + initialTime, + showEncryptionWarning, + onCancel, + onSubmit, +}: SchedulePickerDialogProps) { + const now = Date.now(); + const maxDelay = daysToMs(30); + const defaultTs = initialTime ?? now + hoursToMs(1); + const [ts, setTs] = useState(() => Math.max(defaultTs, now + 60000)); + const [error, setError] = useState(); + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [timePickerCords, setTimePickerCords] = useState(); + const [datePickerCords, setDatePickerCords] = useState(); + + const handleTimePicker: MouseEventHandler = (evt) => { + setTimePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleDatePicker: MouseEventHandler = (evt) => { + setDatePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handlePreset = (offsetMs: number) => { + setTs(Date.now() + offsetMs); + setError(undefined); + }; + + const handleSubmit = () => { + const delay = ts - Date.now(); + if (delay <= 0) { + setError('Scheduled time must be in the future'); + return; + } + if (delay > maxDelay) { + setError('Cannot schedule more than 30 days in advance'); + return; + } + setError(undefined); + onSubmit(new Date(ts)); + }; + + const isPast = ts <= now; + + return ( + }> + + + +
+ + Schedule Send + + + + +
+ + + + + Time + + + } + onClick={handleTimePicker} + > + {timeHourMinute(ts, hour24Clock)} + + setTimePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + +
+ } + /> + + + + + Date + + + } + onClick={handleDatePicker} + > + {timeDayMonthYear(ts)} + + setDatePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + /> + + + + + Quick Schedule + + handlePreset(hoursToMs(1))} + > + In 1 hour + + handlePreset(hoursToMs(4))} + > + In 4 hours + + { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + setTs(tomorrow.getTime()); + setError(undefined); + }} + > + Tomorrow 9 AM + + { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(14, 0, 0, 0); + setTs(tomorrow.getTime()); + setError(undefined); + }} + > + Tomorrow 2 PM + + + + {showEncryptionWarning && ( + + Note: This message will be encrypted with current room keys. Devices that join + or are added after scheduling may not be able to decrypt it. + + )} + {(error || isPast) && ( + + {error || 'Selected time is in the past'} + + )} + + + + +
+
+ ); +} diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.css.ts b/src/app/features/room/schedule-send/ScheduledMessagesList.css.ts new file mode 100644 index 0000000000..18b944c9c9 --- /dev/null +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const ScheduledMessagesToggle = style({ + padding: `${config.space.S100} ${config.space.S400}`, +}); + +export const ScheduledMessagesPanel = style({ + padding: `${config.space.S200} ${config.space.S400}`, + maxHeight: toRem(200), + overflowY: 'auto', +}); + +export const ScheduledMessageRow = style({ + padding: `${config.space.S200} 0`, + borderBottomWidth: config.borderWidth.B300, + borderBottomStyle: 'solid', + borderBottomColor: 'currentcolor', + opacity: 0.8, + selectors: { + '&:last-child': { + borderBottom: 'none', + }, + }, +}); + +export const MessagePreview = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: toRem(300), +}); diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx new file mode 100644 index 0000000000..4c1633b8d9 --- /dev/null +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Box, Text, Chip, Icon, Icons, IconButton } from 'folds'; +import { Room } from '$types/matrix-sdk'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getDelayedEvents, cancelDelayedEvent } from '$utils/delayedEvents'; +import { + delayedEventsSupportedAtom, + roomIdToScheduledTimeAtomFamily, + roomIdToEditingScheduledDelayIdAtomFamily, +} from '$state/scheduledMessages'; +import { timeHourMinute, timeDayMonthYear } from '$utils/time'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { SchedulePickerDialog } from './SchedulePickerDialog'; +import * as css from './ScheduledMessagesList.css'; + +type ScheduledMessagesListProps = { + room: Room; + onEditMessage?: (body: string, formattedBody?: string) => void; +}; + +export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessagesListProps) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const supported = useAtomValue(delayedEventsSupportedAtom); + const setScheduledTime = useSetAtom(roomIdToScheduledTimeAtomFamily(room.roomId)); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [expanded, setExpanded] = useState(false); + const [editingDelayId, setEditingDelayId] = useAtom( + roomIdToEditingScheduledDelayIdAtomFamily(room.roomId) + ); + + const { data } = useQuery({ + queryKey: ['delayedEvents', room.roomId], + queryFn: () => getDelayedEvents(mx), + refetchInterval: 30000, + enabled: supported, + }); + + const roomEvents = data?.delayed_events.filter( + (evt) => + evt.room_id === room.roomId && + (evt.type === 'm.room.message' || evt.type === 'm.room.encrypted') + ); + + const invalidateEvents = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['delayedEvents', room.roomId] }); + }, [queryClient, room.roomId]); + + const handleCancel = useCallback( + async (delayId: string) => { + await cancelDelayedEvent(mx, delayId); + invalidateEvents(); + }, + [mx, invalidateEvents] + ); + + const handleEdit = useCallback( + (delayId: string, body: string, formattedBody?: string, scheduledTs?: number) => { + if (onEditMessage) { + onEditMessage(body, formattedBody); + } + if (scheduledTs) { + setScheduledTime(new Date(scheduledTs)); + } + setEditingDelayId(delayId); + }, + [onEditMessage, setScheduledTime, setEditingDelayId] + ); + + const handleReschedule = useCallback( + (scheduledDate: Date) => { + setScheduledTime(scheduledDate); + setEditingDelayId(null); + }, + [setScheduledTime, setEditingDelayId] + ); + + const visibleEvents = roomEvents?.filter((e) => e.delay_id !== editingDelayId) ?? []; + + if (!supported || visibleEvents.length === 0) { + return null; + } + + return ( + + + } + after={ + + } + onClick={() => setExpanded(!expanded)} + > + + {visibleEvents.length} scheduled{' '} + {visibleEvents.length === 1 ? 'message' : 'messages'} + + + + {expanded && ( + + {visibleEvents.map((evt) => { + const deliveryTs = + 'delay' in evt ? evt.running_since + evt.delay : evt.running_since; + const isEncryptedEvt = evt.type === 'm.room.encrypted'; + const body = isEncryptedEvt + ? '' + : typeof evt.content.body === 'string' + ? evt.content.body + : ''; + const formattedBody = + !isEncryptedEvt && typeof evt.content.formatted_body === 'string' + ? evt.content.formatted_body + : undefined; + + return ( + + + {isEncryptedEvt ? ( + + + + Encrypted — cancel and resend to edit + + + ) : ( + + {body} + + )} + + {timeDayMonthYear(deliveryTs)} at {timeHourMinute(deliveryTs, hour24Clock)} + + + + {!isEncryptedEvt && ( + handleEdit(evt.delay_id, body, formattedBody, deliveryTs)} + aria-label="Edit scheduled message" + > + + + )} + handleCancel(evt.delay_id)} + aria-label="Cancel scheduled message" + > + + + + + ); + })} + + )} + {editingDelayId && !onEditMessage && ( + setEditingDelayId(null)} + onSubmit={handleReschedule} + /> + )} + + ); +} diff --git a/src/app/features/room/schedule-send/index.ts b/src/app/features/room/schedule-send/index.ts new file mode 100644 index 0000000000..060d5a128b --- /dev/null +++ b/src/app/features/room/schedule-send/index.ts @@ -0,0 +1,2 @@ +export { SchedulePickerDialog } from './SchedulePickerDialog'; +export { ScheduledMessagesList } from './ScheduledMessagesList'; diff --git a/src/app/hooks/useDelayedEventsSupport.ts b/src/app/hooks/useDelayedEventsSupport.ts new file mode 100644 index 0000000000..e26de6e04d --- /dev/null +++ b/src/app/hooks/useDelayedEventsSupport.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useSetAtom } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; +import { supportsDelayedEvents } from '$utils/delayedEvents'; + +export function useDelayedEventsSupport(): void { + const mx = useMatrixClient(); + const setSupported = useSetAtom(delayedEventsSupportedAtom); + + useEffect(() => { + let cancelled = false; + supportsDelayedEvents(mx).then((supported) => { + if (!cancelled) setSupported(supported); + }); + return () => { + cancelled = true; + }; + }, [mx, setSupported]); +} diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts new file mode 100644 index 0000000000..03f25e78eb --- /dev/null +++ b/src/app/state/scheduledMessages.ts @@ -0,0 +1,15 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +export const delayedEventsSupportedAtom = atom(false); + +export const roomIdToScheduledTimeAtomFamily = atomFamily>>( + () => atom(null) +); + +// Save the delay_id instead of cancelling the message immediately in case +// the edit process is cancelled +export const roomIdToEditingScheduledDelayIdAtomFamily = atomFamily< + string, + ReturnType> +>(() => atom(null)); diff --git a/src/app/utils/delayedEvents.ts b/src/app/utils/delayedEvents.ts new file mode 100644 index 0000000000..1d36c4eaa9 --- /dev/null +++ b/src/app/utils/delayedEvents.ts @@ -0,0 +1,114 @@ +import { + EventType, + IContent, + MatrixClient, + MatrixEvent, + Room, + UpdateDelayedEventAction, +} from '$types/matrix-sdk'; +import type { + DelayedEventInfo, + SendDelayedEventResponse, +} from '$types/matrix-sdk'; + +// Grab types needed for encryption +interface EncryptableBackend { + encryptEvent(event: MatrixEvent, room: Room): Promise; +} + +export async function supportsDelayedEvents(mx: MatrixClient): Promise { + try { + return await mx.doesServerSupportUnstableFeature('org.matrix.msc4140'); + } catch { + return false; + } +} + +export async function sendDelayedMessage( + mx: MatrixClient, + roomId: string, + content: IContent, + delayMs: number, + threadId?: string | null +): Promise { + return mx._unstable_sendDelayedEvent( + roomId, + { delay: delayMs }, + threadId ?? null, + EventType.RoomMessage, + content as any // eslint-disable-line @typescript-eslint/no-explicit-any + ); +} + +/** + * Send a delayed message in an E2EE room by pre-encrypting the content at + * scheduling time. The message is encrypted with the current Megolm session. + * Devices that join or add new device keys after this call will not be + * able to decrypt it. + */ +export async function sendDelayedMessageE2EE( + mx: MatrixClient, + roomId: string, + room: Room, + content: IContent, + delayMs: number, + threadId?: string | null +): Promise { + const crypto = mx.getCrypto(); + if (!crypto || !('encryptEvent' in crypto)) { + throw new Error('Encryption not available: no crypto backend with encryptEvent'); + } + + // Create a temporary MatrixEvent to encrypt in-place. + const event = new MatrixEvent({ + type: EventType.RoomMessage, + content, + room_id: roomId, + sender: mx.getUserId() ?? '', + event_id: `~${roomId}:${Date.now()}`, + origin_server_ts: Date.now(), + unsigned: {}, + }); + + // Minimal interface to CryptoAPI + await (crypto as unknown as EncryptableBackend).encryptEvent(event, room); + + // After encryption: + // event.getWireType() === 'm.room.encrypted' + // event.getWireContent() === the Megolm ciphertext object + // Pass the pre-encrypted payload directly to the delayed-events API. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (mx as any)._unstable_sendDelayedEvent( + roomId, + { delay: delayMs }, + threadId ?? null, + event.getWireType(), + event.getWireContent() + ); +} + +export async function getDelayedEvents( + mx: MatrixClient +): Promise { + return mx._unstable_getDelayedEvents(); +} + +export async function cancelDelayedEvent( + mx: MatrixClient, + delayId: string +): Promise { + await mx._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel); +} + +export async function sendDelayedEventNow( + mx: MatrixClient, + delayId: string +): Promise { + await mx._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send); +} + +export function computeDelayMs(targetDate: Date): number { + const delay = targetDate.getTime() - Date.now(); + if (delay <= 0) throw new Error('Scheduled time must be in the future'); + return delay; +} From a5d0da94d68a0b233e059da44f58d2099573f184 Mon Sep 17 00:00:00 2001 From: jasonlaguidice <19523621+jasonlaguidice@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:14:09 -0800 Subject: [PATCH 2/2] Fix lint & style issues --- src/app/features/room/RoomInput.tsx | 36 +++++++++++++------ src/app/features/room/RoomView.tsx | 4 +-- .../schedule-send/SchedulePickerDialog.tsx | 27 ++++---------- .../schedule-send/ScheduledMessagesList.tsx | 19 ++++------ src/app/state/scheduledMessages.ts | 7 ++-- src/app/utils/delayedEvents.ts | 23 ++++-------- 6 files changed, 50 insertions(+), 66 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index dfdbb8859a..3c2ca18137 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -126,13 +126,6 @@ import { useImagePackRooms } from '$hooks/useImagePackRooms'; import { useComposingCheck } from '$hooks/useComposingCheck'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { createLogger } from '$utils/debug'; -import { CommandAutocomplete } from './CommandAutocomplete'; -import { - getAudioMsgContent, - getFileMsgContent, - getImageMsgContent, - getVideoMsgContent, -} from './msgContent'; import FocusTrap from 'focus-trap-react'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -146,10 +139,17 @@ import { computeDelayMs, cancelDelayedEvent, } from '$utils/delayedEvents'; -import { SchedulePickerDialog } from './schedule-send'; -import * as css from './schedule-send/SchedulePickerDialog.css'; import { timeHourMinute, timeDayMonthYear } from '$utils/time'; import { stopPropagation } from '$utils/keyboard'; +import { SchedulePickerDialog } from './schedule-send'; +import * as css from './schedule-send/SchedulePickerDialog.css'; +import { + getAudioMsgContent, + getFileMsgContent, + getImageMsgContent, + getVideoMsgContent, +} from './msgContent'; +import { CommandAutocomplete } from './CommandAutocomplete'; const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; @@ -506,7 +506,23 @@ export const RoomInput = forwardRef( } resetInput(); } - }, [mx, roomId, room, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, scheduledTime, setScheduledTime, isEncrypted, queryClient, editingScheduledDelayId, setEditingScheduledDelayId]); + }, [ + mx, + roomId, + room, + editor, + replyDraft, + sendTypingStatus, + setReplyDraft, + isMarkdown, + commands, + scheduledTime, + setScheduledTime, + isEncrypted, + queryClient, + editingScheduledDelayId, + setEditingScheduledDelayId, + ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index bf825699c1..ce8eea406d 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -24,6 +24,8 @@ import { useOpenRoomSettings } from '$state/hooks/roomSettings'; import { useSpaceOptionally } from '$hooks/useSpace'; import { RoomSettingsPage } from '$state/roomSettings'; import { GlobalModalManager } from '$components/message/modals/GlobalModalManager'; +import { useDelayedEventsSupport } from '$hooks/useDelayedEventsSupport'; +import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomInput } from './RoomInput'; import { RoomTombstone } from './RoomTombstone'; @@ -31,8 +33,6 @@ import { RoomViewTyping } from './RoomViewTyping'; import { RoomTimeline } from './RoomTimeline'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { ScheduledMessagesList } from './schedule-send'; -import { useDelayedEventsSupport } from '$hooks/useDelayedEventsSupport'; -import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { diff --git a/src/app/features/room/schedule-send/SchedulePickerDialog.tsx b/src/app/features/room/schedule-send/SchedulePickerDialog.tsx index 123127b847..65744a2eb0 100644 --- a/src/app/features/room/schedule-send/SchedulePickerDialog.tsx +++ b/src/app/features/room/schedule-send/SchedulePickerDialog.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, useState } from 'react'; +import { MouseEventHandler, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Dialog, @@ -139,12 +139,7 @@ export function SchedulePickerDialog({ escapeDeactivates: stopPropagation, }} > - + } /> @@ -185,12 +180,7 @@ export function SchedulePickerDialog({ escapeDeactivates: stopPropagation, }} > - + } /> @@ -244,8 +234,8 @@ export function SchedulePickerDialog({ {showEncryptionWarning && ( - Note: This message will be encrypted with current room keys. Devices that join - or are added after scheduling may not be able to decrypt it. + Note: This message will be encrypted with current room keys. Devices that join or + are added after scheduling may not be able to decrypt it. )} {(error || isPast) && ( @@ -253,12 +243,7 @@ export function SchedulePickerDialog({ {error || 'Selected time is in the past'} )} - diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx index 4c1633b8d9..39f751c9ce 100644 --- a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Box, Text, Chip, Icon, Icons, IconButton } from 'folds'; import { Room } from '$types/matrix-sdk'; @@ -91,28 +91,21 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages variant="SurfaceVariant" radii="Pill" before={} - after={ - - } + after={} onClick={() => setExpanded(!expanded)} > - {visibleEvents.length} scheduled{' '} - {visibleEvents.length === 1 ? 'message' : 'messages'} + {visibleEvents.length} scheduled {visibleEvents.length === 1 ? 'message' : 'messages'} {expanded && ( {visibleEvents.map((evt) => { - const deliveryTs = - 'delay' in evt ? evt.running_since + evt.delay : evt.running_since; + const deliveryTs = 'delay' in evt ? evt.running_since + evt.delay : evt.running_since; const isEncryptedEvt = evt.type === 'm.room.encrypted'; - const body = isEncryptedEvt - ? '' - : typeof evt.content.body === 'string' - ? evt.content.body - : ''; + const body = + !isEncryptedEvt && typeof evt.content.body === 'string' ? evt.content.body : ''; const formattedBody = !isEncryptedEvt && typeof evt.content.formatted_body === 'string' ? evt.content.formatted_body diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts index 03f25e78eb..ba28b60eb2 100644 --- a/src/app/state/scheduledMessages.ts +++ b/src/app/state/scheduledMessages.ts @@ -3,9 +3,10 @@ import { atomFamily } from 'jotai/utils'; export const delayedEventsSupportedAtom = atom(false); -export const roomIdToScheduledTimeAtomFamily = atomFamily>>( - () => atom(null) -); +export const roomIdToScheduledTimeAtomFamily = atomFamily< + string, + ReturnType> +>(() => atom(null)); // Save the delay_id instead of cancelling the message immediately in case // the edit process is cancelled diff --git a/src/app/utils/delayedEvents.ts b/src/app/utils/delayedEvents.ts index 1d36c4eaa9..488c51d1cf 100644 --- a/src/app/utils/delayedEvents.ts +++ b/src/app/utils/delayedEvents.ts @@ -6,10 +6,7 @@ import { Room, UpdateDelayedEventAction, } from '$types/matrix-sdk'; -import type { - DelayedEventInfo, - SendDelayedEventResponse, -} from '$types/matrix-sdk'; +import type { DelayedEventInfo, SendDelayedEventResponse } from '$types/matrix-sdk'; // Grab types needed for encryption interface EncryptableBackend { @@ -36,7 +33,7 @@ export async function sendDelayedMessage( { delay: delayMs }, threadId ?? null, EventType.RoomMessage, - content as any // eslint-disable-line @typescript-eslint/no-explicit-any + content as any ); } @@ -77,7 +74,7 @@ export async function sendDelayedMessageE2EE( // event.getWireType() === 'm.room.encrypted' // event.getWireContent() === the Megolm ciphertext object // Pass the pre-encrypted payload directly to the delayed-events API. - // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (mx as any)._unstable_sendDelayedEvent( roomId, { delay: delayMs }, @@ -87,23 +84,15 @@ export async function sendDelayedMessageE2EE( ); } -export async function getDelayedEvents( - mx: MatrixClient -): Promise { +export async function getDelayedEvents(mx: MatrixClient): Promise { return mx._unstable_getDelayedEvents(); } -export async function cancelDelayedEvent( - mx: MatrixClient, - delayId: string -): Promise { +export async function cancelDelayedEvent(mx: MatrixClient, delayId: string): Promise { await mx._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel); } -export async function sendDelayedEventNow( - mx: MatrixClient, - delayId: string -): Promise { +export async function sendDelayedEventNow(mx: MatrixClient, delayId: string): Promise { await mx._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send); }