diff --git a/.changeset/chubby-lights-send.md b/.changeset/chubby-lights-send.md new file mode 100644 index 00000000..fc52b12e --- /dev/null +++ b/.changeset/chubby-lights-send.md @@ -0,0 +1,6 @@ +--- +'@matrix-widget-toolkit/testing': minor +'@matrix-widget-toolkit/api': minor +--- + +Add support for the delayed events diff --git a/example-widget-mui/src/DicePage/DicePage.test.tsx b/example-widget-mui/src/DicePage/DicePage.test.tsx index 4f504ffe..dee3c163 100644 --- a/example-widget-mui/src/DicePage/DicePage.test.tsx +++ b/example-widget-mui/src/DicePage/DicePage.test.tsx @@ -19,7 +19,11 @@ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; -import { EventDirection, WidgetEventCapability } from 'matrix-widget-api'; +import { + EventDirection, + UpdateDelayedEventAction, + WidgetEventCapability, +} from 'matrix-widget-api'; import { ComponentType, PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -55,7 +59,7 @@ describe('', () => { screen.getByText(/nobody has thrown the dice in this room yet/i), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /throw dice/i }), + screen.getByRole('button', { name: /throw dice$/i }), ).toBeInTheDocument(); }); @@ -79,7 +83,7 @@ describe('', () => { ), ]); - const button = await screen.findByRole('button', { name: /throw dice/i }); + const button = await screen.findByRole('button', { name: /throw dice$/i }); await userEvent.click(button); expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([ @@ -124,7 +128,7 @@ describe('', () => { it('should throw a dice', async () => { render(, { wrapper }); - const button = await screen.findByRole('button', { name: /throw dice/i }); + const button = await screen.findByRole('button', { name: /throw dice$/i }); await userEvent.click(button); await expect( @@ -138,4 +142,75 @@ describe('', () => { }, ); }); + + it('should throw a dice delayed', async () => { + render(, { wrapper }); + + const button = await screen.findByRole('button', { + name: /throw dice 10 seconds delayed/i, + }); + await userEvent.click(button); + + await expect( + screen.findByText(/your last throw: ./i), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText(/your last delay id: ./i), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText(/throw dice delayed event actions:/i), + ).resolves.toBeInTheDocument(); + + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + 'net.nordeck.throw_dice', + { + pips: expect.any(Number), + }, + expect.any(Number), + ); + }); + + it.each([ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ])('should update (%s) a throw dice delayed', async (action) => { + render(, { wrapper }); + + const button = await screen.findByRole('button', { + name: /throw dice 10 seconds delayed/i, + }); + await userEvent.click(button); + + await expect( + screen.findByText(/your last throw: ./i), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText(/Your last delay id: ./i), + ).resolves.toBeInTheDocument(); + + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + 'net.nordeck.throw_dice', + { + pips: expect.any(Number), + }, + expect.any(Number), + ); + + const updateButton = await screen.findByRole('button', { + name: new RegExp(action, 'i'), + }); + await userEvent.click(updateButton); + + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( + 'syd_wlGAStYmBRRdjnWiHSDA', + action, + ); + + if (action === UpdateDelayedEventAction.Restart) { + expect(updateButton).toBeInTheDocument(); + } else { + expect(updateButton).not.toBeInTheDocument(); + } + }); }); diff --git a/example-widget-mui/src/DicePage/DicePage.tsx b/example-widget-mui/src/DicePage/DicePage.tsx index d12f9cc3..acaba8e1 100644 --- a/example-widget-mui/src/DicePage/DicePage.tsx +++ b/example-widget-mui/src/DicePage/DicePage.tsx @@ -18,21 +18,29 @@ import { MuiCapabilitiesGuard } from '@matrix-widget-toolkit/mui'; import { useWidgetApi } from '@matrix-widget-toolkit/react'; import { Alert, + AlertTitle, Box, Button, + ButtonGroup, Card, CardContent, Typography, } from '@mui/material'; -import { EventDirection, WidgetEventCapability } from 'matrix-widget-api'; -import { ReactElement, useEffect, useState } from 'react'; +import { + EventDirection, + MatrixCapabilities, + UpdateDelayedEventAction, + WidgetEventCapability, +} from 'matrix-widget-api'; +import { ReactElement, useEffect, useRef, useState } from 'react'; import { filter, map } from 'rxjs'; import { NavigationBar } from '../NavigationPage'; import { + isValidThrowDiceEvent, STATE_EVENT_THROW_DICE, ThrowDiceEvent, - isValidThrowDiceEvent, } from '../events'; +import { isError } from '../utils'; /** * A component that reads and writes room events via the widget API. @@ -58,11 +66,19 @@ export const DicePage = (): ReactElement => { ); }; +type Timeout = ReturnType; +const eventDelayMs = 10000; + export const DiceView = (): ReactElement => { const widgetApi = useWidgetApi(); const [lastOwnDice, setLastOwnDice] = useState(); + const [lastDelayId, setLastDelayId] = useState(); + const [lastDelayIdExpired, setLastDelayIdExpired] = useState(false); + const [lastDelayError, setLastDelayError] = useState(); const [dices, setDices] = useState([]); + const lastDelayIdTimeoutRef = useRef(); + useEffect(() => { setDices([]); @@ -81,6 +97,12 @@ export const DiceView = (): ReactElement => { }; }, [widgetApi]); + useEffect(() => { + return () => { + clearTimeout(lastDelayIdTimeoutRef.current); + }; + }, []); + async function handleThrowDice() { await widgetApi.requestCapabilities([ WidgetEventCapability.forRoomEvent( @@ -97,9 +119,68 @@ export const DiceView = (): ReactElement => { setLastOwnDice(result.content.pips); } + async function handleThrowDiceDelayed() { + await widgetApi.requestCapabilities([ + MatrixCapabilities.MSC4157SendDelayedEvent, + WidgetEventCapability.forRoomEvent( + EventDirection.Send, + STATE_EVENT_THROW_DICE, + ), + ]); + + const pips = Math.floor(Math.random() * 6) + 1; + try { + const { delay_id } = await widgetApi.sendDelayedRoomEvent( + STATE_EVENT_THROW_DICE, + { pips }, + eventDelayMs, + ); + setLastOwnDice(pips); + setLastDelayId(delay_id); + setLastDelayIdExpired(false); + setLastDelayError(undefined); + + clearTimeout(lastDelayIdTimeoutRef.current); + lastDelayIdTimeoutRef.current = setTimeout(() => { + setLastDelayIdExpired(true); + }, eventDelayMs); + } catch { + setLastDelayError( + 'Could not send a delayed event. Please check if homeserver supports delayed events.', + ); + } + } + + async function handleThrowDiceDelayedUpdate( + action: UpdateDelayedEventAction, + ) { + if (!lastDelayId) { + return; + } + + await widgetApi.requestCapabilities([ + MatrixCapabilities.MSC4157UpdateDelayedEvent, + ]); + + try { + await widgetApi.updateDelayedEvent(lastDelayId, action); + if (action === UpdateDelayedEventAction.Restart) { + clearTimeout(lastDelayIdTimeoutRef.current); + lastDelayIdTimeoutRef.current = setTimeout(() => { + setLastDelayIdExpired(true); + }, eventDelayMs); + } else { + setLastDelayIdExpired(true); + } + setLastDelayError(undefined); + } catch (e) { + setLastDelayError(isError(e) ? e.message : JSON.stringify(e)); + } + } + return ( <> - + Dice Simulator @@ -113,15 +194,66 @@ export const DiceView = (): ReactElement => { - + + {lastOwnDice && ( - + Your last throw: )} + + {lastDelayError && ( + + Error + {lastDelayError} + + )} + + {lastDelayId && ( + + Your last delay id: {lastDelayId} + + )} + + {lastDelayId && !lastDelayIdExpired && ( + <> + + Throw dice delayed event actions: + + + + {Object.values(UpdateDelayedEventAction).map((action) => ( + + ))} + + + )} ); }; @@ -138,3 +270,7 @@ const pipsEmojis: Record = { export const Dice = ({ pips }: { pips: number }): ReactElement => { return <>{pipsEmojis[pips] ?? ''}; }; + +function getTextForAction(action: UpdateDelayedEventAction) { + return action.charAt(0).toUpperCase() + action.slice(1); +} diff --git a/packages/api/README.md b/packages/api/README.md index ce0c345e..760ac85a 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -148,3 +148,12 @@ It returns a promise that resolves with the result of the modal. Inside the modal widget, you can use `observeModalButtons()` to listen to clicks on the bottom buttons of the modal. You can use `setModalButtonEnabled()` to disable buttons from within the widget. Once you are done, you can call `closeModal()` to close the modal and pass the results back to the main widget. + +### Delayed events + +You can send and update delayed events (MSC4140). The configuration for delayed events on the homeserver +needs to be applied, for example: + +``` +max_event_delay_duration: 24h +``` diff --git a/packages/api/api-report.api.md b/packages/api/api-report.api.md index 9e7d81e3..f09096bc 100644 --- a/packages/api/api-report.api.md +++ b/packages/api/api-report.api.md @@ -20,6 +20,7 @@ import { IWidgetApiRequestData } from 'matrix-widget-api'; import { ModalButtonID } from 'matrix-widget-api'; import { Observable } from 'rxjs'; import { Symbols } from 'matrix-widget-api'; +import { UpdateDelayedEventAction } from 'matrix-widget-api'; import { WidgetApi as WidgetApi_2 } from 'matrix-widget-api'; import { WidgetEventCapability } from 'matrix-widget-api'; @@ -239,6 +240,12 @@ export type WidgetApi = { roomId?: string; stateKey?: string; }): Promise; + sendDelayedStateEvent(eventType: string, content: T, delay: number, options?: { + roomId?: string; + stateKey?: string; + }): Promise<{ + delay_id: string; + }>; receiveRoomEvents(eventType: string, options?: { messageType?: string; roomIds?: string[] | Symbols.AnyRoom; @@ -250,6 +257,12 @@ export type WidgetApi = { sendRoomEvent(eventType: string, content: T, options?: { roomId?: string; }): Promise>; + sendDelayedRoomEvent(eventType: string, content: T, delay: number, options?: { + roomId?: string; + }): Promise<{ + delay_id: string; + }>; + updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise; readEventRelations(eventId: string, options?: { roomId?: string; limit?: number; @@ -354,6 +367,17 @@ export class WidgetApiImpl implements WidgetApi { avatarUrl?: string; }>; }>; + sendDelayedRoomEvent(eventType: string, content: T, delay: number, { roomId }?: { + roomId?: string; + }): Promise<{ + delay_id: string; + }>; + sendDelayedStateEvent(eventType: string, content: T, delay: number, { roomId, stateKey }?: { + roomId?: string; + stateKey?: string; + }): Promise<{ + delay_id: string; + }>; sendRoomEvent(eventType: string, content: T, { roomId }?: { roomId?: string; }): Promise>; @@ -367,6 +391,7 @@ export class WidgetApiImpl implements WidgetApi { }; }): Promise; setModalButtonEnabled(buttonId: ModalButtonID, isEnabled: boolean): Promise; + updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise; uploadFile(file: XMLHttpRequestBodyInit): Promise; readonly widgetId: string; readonly widgetParameters: WidgetParameters; diff --git a/packages/api/src/api/WidgetApiImpl.ts b/packages/api/src/api/WidgetApiImpl.ts index 91105d7d..41a31038 100644 --- a/packages/api/src/api/WidgetApiImpl.ts +++ b/packages/api/src/api/WidgetApiImpl.ts @@ -34,6 +34,7 @@ import { ModalButtonID, Symbols, UnstableApiVersion, + UpdateDelayedEventAction, WidgetApiToWidgetAction, WidgetEventCapability, } from 'matrix-widget-api'; @@ -462,6 +463,28 @@ export class WidgetApiImpl implements WidgetApi { ); } + /** {@inheritDoc WidgetApi.sendDelayedStateEvent} */ + async sendDelayedStateEvent( + eventType: string, + content: T, + delay: number, + { roomId, stateKey = '' }: { roomId?: string; stateKey?: string } = {}, + ): Promise<{ delay_id: string }> { + const { delay_id } = await this.matrixWidgetApi.sendStateEvent( + eventType, + stateKey, + content, + roomId, + delay, + ); + + if (!delay_id) { + throw new Error('Delayed event must have a delay_id'); + } + + return { delay_id }; + } + /** {@inheritDoc WidgetApi.receiveRoomEvents} */ async receiveRoomEvents( eventType: string, @@ -558,6 +581,35 @@ export class WidgetApiImpl implements WidgetApi { } } + /** {@inheritDoc WidgetApi.sendDelayedRoomEvent} */ + async sendDelayedRoomEvent( + eventType: string, + content: T, + delay: number, + { roomId }: { roomId?: string } = {}, + ): Promise<{ delay_id: string }> { + const { delay_id } = await this.matrixWidgetApi.sendRoomEvent( + eventType, + content, + roomId, + delay, + ); + + if (!delay_id) { + throw new Error('Delayed event must have a delay_id'); + } + + return { delay_id }; + } + + /** {@inheritDoc WidgetApi.updateDelayedEvent} */ + async updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + ): Promise { + await this.matrixWidgetApi.updateDelayedEvent(delayId, action); + } + /** {@inheritDoc WidgetApi.readEventRelations} */ async readEventRelations( eventId: string, diff --git a/packages/api/src/api/types.ts b/packages/api/src/api/types.ts index b824d2d7..c10a454b 100644 --- a/packages/api/src/api/types.ts +++ b/packages/api/src/api/types.ts @@ -30,6 +30,7 @@ import { IWidgetApiRequestData, ModalButtonID, Symbols, + UpdateDelayedEventAction, WidgetEventCapability, } from 'matrix-widget-api'; import { Observable } from 'rxjs'; @@ -336,6 +337,24 @@ export type WidgetApi = { options?: { roomId?: string; stateKey?: string }, ): Promise; + /** + * Send a delayed state event with a given type to the current room. + * @param eventType - The type of the event to send. + * @param content - The content of the event. + * @param delay - The delay of the event in milliseconds. + * @param options - Options for sending the state event. + * Use `roomId` to send the state event to another room. + * Use `stateKey` to send a state event with a custom state + * key. + * @returns The result data of delayed event with delay_id. + */ + sendDelayedStateEvent( + eventType: string, + content: T, + delay: number, + options?: { roomId?: string; stateKey?: string }, + ): Promise<{ delay_id: string }>; + /** * Receive all room events of a given type from the current room. * @@ -394,6 +413,34 @@ export type WidgetApi = { options?: { roomId?: string }, ): Promise>; + /** + * Send a delayed room event with a given type to the current room. + * @param eventType - The type of the event to send. + * @param content - The content of the event. + * @param delay - The delay of the event in milliseconds. + * @param options - Options for sending the state event. + * Use `roomId` to send the state event to another room. + * Use `stateKey` to send a state event with a custom state + * key. + * @returns The result data of delayed event with delay_id. + */ + sendDelayedRoomEvent( + eventType: string, + content: T, + delay: number, + options?: { roomId?: string }, + ): Promise<{ delay_id: string }>; + + /** + * Update a delayed event by delay id + * @param delayId - The delay id of the event + * @param action - The action to update + */ + updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + ): Promise; + /** * Receive all events that relate to a given `eventId` by means of MSC2674. * `chunk` can include state events or room events. diff --git a/packages/testing/src/api/mockWidgetApi.test.ts b/packages/testing/src/api/mockWidgetApi.test.ts index f2bd2ead..cdbcc852 100644 --- a/packages/testing/src/api/mockWidgetApi.test.ts +++ b/packages/testing/src/api/mockWidgetApi.test.ts @@ -15,7 +15,7 @@ */ import { redactEvent, StateEvent } from '@matrix-widget-toolkit/api'; -import { Symbols } from 'matrix-widget-api'; +import { Symbols, UpdateDelayedEventAction } from 'matrix-widget-api'; import { bufferTime, firstValueFrom, Observable, take } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { MockedWidgetApi, mockWidgetApi } from './mockWidgetApi'; @@ -170,6 +170,31 @@ describe('sendRoomEvent', () => { }); }); +describe('sendDelayedRoomEvent', () => { + it('should send delayed room event', async () => { + await expect( + widgetApi.sendDelayedRoomEvent( + 'com.example.test', + { + key: 'value', + }, + 1000, + ), + ).resolves.toEqual({ delay_id: 'syd_wlGAStYmBRRdjnWiHSDA' }); + }); +}); + +describe('updateDelayedEvent', () => { + it('should update delayed event', async () => { + await expect( + widgetApi.updateDelayedEvent( + 'syd_wlGAStYmBRRdjnWiHSDA', + UpdateDelayedEventAction.Cancel, + ), + ).resolves.toBeUndefined(); + }); +}); + describe('receiveRoomEvents', () => { it('should receive only events from the current room', async () => { await expect( @@ -395,6 +420,20 @@ describe('sendStateEvent', () => { }); }); +describe('sendDelayedStateEvent', () => { + it('should send delayed state event', async () => { + await expect( + widgetApi.sendDelayedStateEvent( + 'com.example.test', + { + key: 'value', + }, + 1000, + ), + ).resolves.toEqual({ delay_id: 'syd_bcooaGNyKtyFbIGjGMQR' }); + }); +}); + describe('receiveSingleStateEvent', () => { it('should receive event from the current room with a default state key', async () => { await expect( diff --git a/packages/testing/src/api/mockWidgetApi.ts b/packages/testing/src/api/mockWidgetApi.ts index ee58dd5c..b899a7d4 100644 --- a/packages/testing/src/api/mockWidgetApi.ts +++ b/packages/testing/src/api/mockWidgetApi.ts @@ -212,8 +212,15 @@ export function mockWidgetApi(opts?: { // @ts-expect-error -- Mocks are expected to return no proper T type observeRoomEvents: vi.fn(), sendStateEvent: vi.fn(), + sendDelayedStateEvent: vi + .fn() + .mockResolvedValue({ delay_id: 'syd_bcooaGNyKtyFbIGjGMQR' }), // @ts-expect-error -- Mocks are expected to return no proper T type sendRoomEvent: vi.fn(), + sendDelayedRoomEvent: vi + .fn() + .mockResolvedValue({ delay_id: 'syd_wlGAStYmBRRdjnWiHSDA' }), + updateDelayedEvent: vi.fn().mockResolvedValue(undefined), closeModal: vi.fn().mockResolvedValue(undefined), // @ts-expect-error -- Mocks are expected to return no proper T type getWidgetConfig: vi.fn(),