diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.test.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.test.tsx new file mode 100644 index 000000000..2137f9248 --- /dev/null +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.test.tsx @@ -0,0 +1,197 @@ +/* oxlint-disable typescript/no-explicit-any, typescript/no-extraneous-class, unicorn/consistent-function-scoping, vitest/require-mock-type-parameters */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ScheduledMessagesList } from './ScheduledMessagesList'; +import type * as MatrixSdkModule from '$types/matrix-sdk'; + +const testState = vi.hoisted(() => ({ + cancelDelayedEvent: vi.fn(), + coordinatorRun: vi.fn(), + invalidateQueries: vi.fn(), + matrix: { + getSafeUserId: vi.fn(() => '@me:example.org'), + }, +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => testState.matrix, +})); + +vi.mock('$utils/delayedEvents', () => ({ + cancelDelayedEvent: testState.cancelDelayedEvent, + getDelayedEvents: vi.fn(), +})); + +vi.mock('$state/room/roomScheduleCoordinator', () => ({ + roomScheduleCoordinator: { + run: testState.coordinatorRun, + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ + data: { + delayed_events: [ + { + delay_id: 'delay-1', + room_id: '!room:example.org', + type: 'm.room.message', + content: { body: 'Scheduled message' }, + running_since: 1_000, + delay: 60_000, + }, + ], + }, + }), + useQueryClient: () => ({ invalidateQueries: testState.invalidateQueries }), +})); + +vi.mock('$state/scheduledMessages', async () => { + const { atom } = await import('jotai'); + const scheduledTimeAtom = atom(null); + const editingScheduledDelayIdAtom = atom(null); + + return { + delayedEventsSupportedAtom: atom(true), + roomIdToScheduledTimeAtomFamily: () => scheduledTimeAtom, + roomIdToEditingScheduledDelayIdAtomFamily: () => editingScheduledDelayIdAtom, + }; +}); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => [ + key === 'hour24Clock' ? false : 'YYYY-MM-DD', + vi.fn(), + ], +})); + +vi.mock('$state/settings', () => ({ settingsAtom: {} })); + +vi.mock('$types/matrix-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + MatrixEvent: class MatrixEvent {}, +})); + +vi.mock('$utils/time', () => ({ + timeDayMonthYear: () => '2026-07-28', + timeHourMinute: () => '00:01', +})); + +vi.mock('$components/message-preview', () => ({ + MessagePreview: ({ actions, event }: any) => ( +
+ {event.getContent?.().body ?? 'Scheduled message'} + {actions} +
+ ), + useRoomMessagePreviewRenderer: () => vi.fn(), +})); + +vi.mock('$components/icons/phosphor', () => ({ + CaretDown: 'CaretDown', + CaretUp: 'CaretUp', + Clock: 'Clock', + Lock: 'Lock', + PencilSimple: 'PencilSimple', + X: 'X', + chipIcon: () => null, +})); + +vi.mock('folds', () => { + const Box = ({ children, ...props }: any) =>
{children}
; + const Text = ({ children, ...props }: any) => {children}; + const Button = ({ children, ...props }: any) => ; + + return { + Box, + Chip: Button, + IconButton: Button, + Spinner: () => Cancelling, + Text, + config: { + borderWidth: { B300: '1px' }, + space: { S100: '1px', S200: '2px', S400: '4px' }, + }, + toRem: (value: number) => `${value / 16}rem`, + }; +}); + +vi.mock('./SchedulePickerDialog', () => ({ + SchedulePickerDialog: () => null, +})); + +const room = { roomId: '!room:example.org' } as any; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function renderExpandedList() { + render(); + fireEvent.click(screen.getByRole('button', { name: /1 scheduled message/i })); + return screen.getByRole('button', { name: 'Cancel scheduled message' }); +} + +describe('ScheduledMessagesList cancellation', () => { + beforeEach(() => { + testState.cancelDelayedEvent.mockReset(); + testState.coordinatorRun.mockReset(); + testState.invalidateQueries.mockReset(); + testState.coordinatorRun.mockImplementation((_roomId: string, operation: () => unknown) => + operation() + ); + }); + + it('blocks duplicate cancellation clicks and shows pending state', () => { + const pending = deferred(); + testState.cancelDelayedEvent.mockReturnValue(pending.promise); + + const cancel = renderExpandedList(); + fireEvent.click(cancel); + fireEvent.click(cancel); + + expect(testState.coordinatorRun).toHaveBeenCalledWith(room.roomId, expect.any(Function)); + expect(testState.cancelDelayedEvent).toHaveBeenCalledOnce(); + expect(cancel).toBeDisabled(); + expect(cancel).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows a retryable alert when cancellation fails', async () => { + testState.cancelDelayedEvent.mockRejectedValueOnce(new Error('Cancel failed')); + const cancel = renderExpandedList(); + + fireEvent.click(cancel); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'Failed to cancel scheduled message. Try again.' + ) + ); + expect(cancel).not.toBeDisabled(); + expect(cancel).not.toHaveAttribute('aria-busy', 'true'); + }); + + it('successfully retries cancellation after a failure', async () => { + testState.cancelDelayedEvent + .mockRejectedValueOnce(new Error('Cancel failed')) + .mockResolvedValueOnce(undefined); + const cancel = renderExpandedList(); + + fireEvent.click(cancel); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + + fireEvent.click(cancel); + + await waitFor(() => expect(testState.cancelDelayedEvent).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(testState.invalidateQueries).toHaveBeenCalledOnce()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx index 16583b392..c9aafa05a 100644 --- a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Box, Text, Chip, IconButton } from 'folds'; +import { Box, Text, Chip, IconButton, Spinner } from 'folds'; import { CaretDown, CaretUp, @@ -27,6 +27,7 @@ import { import { timeHourMinute, timeDayMonthYear } from '$utils/time'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; import { MessagePreview, useRoomMessagePreviewRenderer } from '$components/message-preview'; import { SchedulePickerDialog } from './SchedulePickerDialog'; import * as css from './ScheduledMessagesList.css'; @@ -44,6 +45,12 @@ type ScheduledMessageRowProps = { hour24Clock: boolean; onEdit: (delayId: string, body: string, formattedBody?: string, scheduledTs?: number) => void; onCancel: (delayId: string) => void; + cancellationState: CancellationState; +}; + +type CancellationState = { + status: 'idle' | 'pending' | 'error'; + error?: string; }; function ScheduledMessageRow({ @@ -52,6 +59,7 @@ function ScheduledMessageRow({ hour24Clock, onEdit, onCancel, + cancellationState, }: ScheduledMessageRowProps) { const mx = useMatrixClient(); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); @@ -63,6 +71,8 @@ function ScheduledMessageRow({ !isEncrypted && typeof event.content.formatted_body === 'string' ? event.content.formatted_body : undefined; + const isCancelling = cancellationState.status === 'pending'; + const cancelIcon = isCancelling ? : chipIcon(X); const matrixEvent = useMemo( () => new MatrixEvent({ @@ -106,9 +116,11 @@ function ScheduledMessageRow({ variant="Critical" radii="300" onClick={() => onCancel(event.delay_id)} + disabled={isCancelling} + aria-busy={isCancelling} aria-label="Cancel scheduled message" > - {chipIcon(X)} + {cancelIcon} } @@ -126,12 +138,19 @@ function ScheduledMessageRow({ variant="Critical" radii="300" onClick={() => onCancel(event.delay_id)} + disabled={isCancelling} + aria-busy={isCancelling} aria-label="Cancel scheduled message" > - {chipIcon(X)} + {cancelIcon} )} + {cancellationState.status === 'error' && ( + + {cancellationState.error} + + )} ); } @@ -146,6 +165,10 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages const [editingDelayId, setEditingDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(room.roomId) ); + const [cancellationStates, setCancellationStates] = useState>( + {} + ); + const pendingCancellations = useRef(new Set()); const { data } = useQuery({ queryKey: ['delayedEvents', room.roomId], @@ -167,10 +190,35 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages const handleCancel = useCallback( async (delayId: string) => { - await cancelDelayedEvent(mx, delayId); - invalidateEvents(); + if (pendingCancellations.current.has(delayId)) return; + + pendingCancellations.current.add(delayId); + setCancellationStates((states) => ({ + ...states, + [delayId]: { status: 'pending' }, + })); + + try { + await roomScheduleCoordinator.run(room.roomId, () => cancelDelayedEvent(mx, delayId)); + invalidateEvents(); + setCancellationStates((states) => { + const next = { ...states }; + delete next[delayId]; + return next; + }); + } catch { + setCancellationStates((states) => ({ + ...states, + [delayId]: { + status: 'error', + error: 'Failed to cancel scheduled message. Try again.', + }, + })); + } finally { + pendingCancellations.current.delete(delayId); + } }, - [mx, invalidateEvents] + [mx, room.roomId, invalidateEvents] ); const handleEdit = useCallback( @@ -218,6 +266,7 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages hour24Clock={hour24Clock} onEdit={handleEdit} onCancel={handleCancel} + cancellationState={cancellationStates[event.delay_id] ?? { status: 'idle' }} /> ))} diff --git a/src/app/state/room/roomScheduleCoordinator.test.ts b/src/app/state/room/roomScheduleCoordinator.test.ts new file mode 100644 index 000000000..98f67e548 --- /dev/null +++ b/src/app/state/room/roomScheduleCoordinator.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { roomScheduleCoordinator } from './roomScheduleCoordinator'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('roomScheduleCoordinator', () => { + it('runs operations in FIFO order for a room', async () => { + const first = deferred(); + const order: string[] = []; + + const firstOperation = roomScheduleCoordinator.run('!room:example.org', async () => { + order.push('first-start'); + await first.promise; + order.push('first-end'); + }); + const secondOperation = roomScheduleCoordinator.run('!room:example.org', async () => { + order.push('second'); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['first-start']); + + first.resolve(); + await Promise.all([firstOperation, secondOperation]); + expect(order).toEqual(['first-start', 'first-end', 'second']); + }); + + it('continues the room queue after an operation fails', async () => { + const second = vi.fn<() => Promise>(async () => 'completed'); + + await expect( + roomScheduleCoordinator.run('!room:example.org', async () => { + throw new Error('cancel failed'); + }) + ).rejects.toThrow('cancel failed'); + await expect(roomScheduleCoordinator.run('!room:example.org', second)).resolves.toBe( + 'completed' + ); + + expect(second).toHaveBeenCalledOnce(); + }); + + it('does not serialize operations for different rooms', async () => { + const first = deferred(); + const second = vi.fn<() => Promise>(async () => 'completed'); + + const firstOperation = roomScheduleCoordinator.run('!first:example.org', () => first.promise); + const secondOperation = roomScheduleCoordinator.run('!second:example.org', second); + + await expect(secondOperation).resolves.toBe('completed'); + expect(second).toHaveBeenCalledOnce(); + + first.resolve(); + await firstOperation; + }); +}); diff --git a/src/app/state/room/roomScheduleCoordinator.ts b/src/app/state/room/roomScheduleCoordinator.ts new file mode 100644 index 000000000..ddfa595ed --- /dev/null +++ b/src/app/state/room/roomScheduleCoordinator.ts @@ -0,0 +1,4 @@ +import { createKeyedQueue } from '$utils/keyedQueue'; + +/** Serializes delayed-event mutations for each room without blocking other rooms. */ +export const roomScheduleCoordinator = { run: createKeyedQueue() };