diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 8aaf042c98..3c2ca18137 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, @@ -123,13 +126,30 @@ 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 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 { 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 {}; @@ -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,70 @@ 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 +697,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 +931,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..ce8eea406d 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'; @@ -22,12 +24,15 @@ 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'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTimeline } from './RoomTimeline'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; +import { ScheduledMessagesList } from './schedule-send'; 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..39f751c9ce --- /dev/null +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx @@ -0,0 +1,175 @@ +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'; +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..ba28b60eb2 --- /dev/null +++ b/src/app/state/scheduledMessages.ts @@ -0,0 +1,16 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +export const delayedEventsSupportedAtom = atom(false); + +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 +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..488c51d1cf --- /dev/null +++ b/src/app/utils/delayedEvents.ts @@ -0,0 +1,103 @@ +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 + ); +} + +/** + * 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. + + 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; +}