From 5a8560f5f1d0db523ddef2b0b4178d9dc3cbaead Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 3 Dec 2024 10:20:31 +0100 Subject: [PATCH 1/2] Change WidgetApi.sendStateEvent to return the Matrix widget Api result. Before, it waited for the same event sent back over the Widget API. If the was not updated, there was no such event. Now sendStateEvent returns the response of the Matrix Widget Api with the event ID, among other things. Signed-off-by: Michael Weimann --- .changeset/khaki-mayflies-notice.md | 5 + .../src/PowerLevelsPage/PowerLevelsPage.tsx | 2 +- .../PowerLevelsPage/powerLevelsApi.test.ts | 18 +-- .../src/PowerLevelsPage/powerLevelsApi.ts | 18 +-- packages/api/api-report.api.md | 5 +- packages/api/src/api/WidgetApiImpl.test.ts | 134 ++++-------------- packages/api/src/api/WidgetApiImpl.ts | 40 ++---- packages/api/src/api/types.ts | 3 +- packages/testing/src/api/mockWidgetApi.ts | 1 - 9 files changed, 58 insertions(+), 168 deletions(-) create mode 100644 .changeset/khaki-mayflies-notice.md diff --git a/.changeset/khaki-mayflies-notice.md b/.changeset/khaki-mayflies-notice.md new file mode 100644 index 00000000..43731043 --- /dev/null +++ b/.changeset/khaki-mayflies-notice.md @@ -0,0 +1,5 @@ +--- +'@matrix-widget-toolkit/api': major +--- + +WidgetApi.sendStateEvent no longer returns the event. Instead it returns the result of the Matrix Widget API. diff --git a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx index f1b0893e..64e10c41 100644 --- a/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx +++ b/example-widget-mui/src/PowerLevelsPage/PowerLevelsPage.tsx @@ -92,7 +92,7 @@ export const PowerLevelsPage = (): ReactElement => { ), ]} > - {/* + {/* The StoreProvider is located here to keep the example small. Normal applications would located it outside of the router to establish a single, global store. diff --git a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts index 08aae48f..21a258ef 100644 --- a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts +++ b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.test.ts @@ -94,22 +94,14 @@ describe('getPowerLevels', () => { }); describe('updatePowerLevels', () => { - it('should update the topic', async () => { + it('should update the power levels', async () => { const store = createStore({ widgetApi }); - await expect( - store - .dispatch( - powerLevelsApi.endpoints.updatePowerLevels.initiate({ - users_default: 100, - }), - ) - .unwrap(), - ).resolves.toMatchObject({ - content: { + await store.dispatch( + powerLevelsApi.endpoints.updatePowerLevels.initiate({ users_default: 100, - }, - }); + }), + ); expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( 'm.room.power_levels', diff --git a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts index 1d604106..1648c71b 100644 --- a/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts +++ b/example-widget-mui/src/PowerLevelsPage/powerLevelsApi.ts @@ -17,7 +17,6 @@ import { PowerLevelsStateEvent, STATE_EVENT_POWER_LEVELS, - StateEvent, isValidPowerLevelStateEvent, } from '@matrix-widget-toolkit/api'; import { EventDirection, WidgetEventCapability } from 'matrix-widget-api'; @@ -91,11 +90,8 @@ export const powerLevelsApi = baseApi.injectEndpoints({ }), /** Update the name of the current room */ - updatePowerLevels: builder.mutation< - StateEvent, - PowerLevelsStateEvent - >({ - // Optimistic update the local cache to instantly see the updated room name. + updatePowerLevels: builder.mutation({ + // Optimistic update the local cache to instantly see the updated power levels. // Undo the change if the query fails. async onQueryStarted(content, { dispatch, queryFulfilled }) { const { undo } = dispatch( @@ -128,12 +124,12 @@ export const powerLevelsApi = baseApi.injectEndpoints({ ), ]); - const newEvent = await widgetApi.sendStateEvent( - STATE_EVENT_POWER_LEVELS, - content, - ); + await widgetApi.sendStateEvent(STATE_EVENT_POWER_LEVELS, content); - return { data: newEvent }; + // We don't care about the result here. + // When executing the mutation, an optimistic update is already done. + // Otherwise, the new event should come down the sync. + return { data: null }; } catch (e) { return { error: { diff --git a/packages/api/api-report.api.md b/packages/api/api-report.api.md index 164ecc46..f116b3b2 100644 --- a/packages/api/api-report.api.md +++ b/packages/api/api-report.api.md @@ -12,6 +12,7 @@ import { IModalWidgetOpenRequestDataButton } from 'matrix-widget-api'; import { IModalWidgetReturnData } from 'matrix-widget-api'; import { IOpenIDCredentials } from 'matrix-widget-api'; import { IRoomEvent } from 'matrix-widget-api'; +import { ISendEventFromWidgetResponseData } from 'matrix-widget-api'; import { IUploadFileActionFromWidgetResponseData } from 'matrix-widget-api'; import { IWidget } from 'matrix-widget-api'; import { IWidgetApiRequest } from 'matrix-widget-api'; @@ -222,7 +223,7 @@ export type WidgetApi = { sendStateEvent(eventType: string, content: T, options?: { roomId?: string; stateKey?: string; - }): Promise>; + }): Promise; receiveRoomEvents(eventType: string, options?: { messageType?: string; roomIds?: string[] | Symbols.AnyRoom; @@ -344,7 +345,7 @@ export class WidgetApiImpl implements WidgetApi { sendStateEvent(eventType: string, content: T, { roomId, stateKey }?: { roomId?: string; stateKey?: string; - }): Promise>; + }): Promise; sendToDeviceMessage(eventType: string, encrypted: boolean, content: { [userId: string]: { [deviceId: string | '*']: T; diff --git a/packages/api/src/api/WidgetApiImpl.test.ts b/packages/api/src/api/WidgetApiImpl.test.ts index bc106c6d..0c45458b 100644 --- a/packages/api/src/api/WidgetApiImpl.test.ts +++ b/packages/api/src/api/WidgetApiImpl.test.ts @@ -1060,76 +1060,33 @@ describe('WidgetApiImpl', () => { describe('sendStateEvent', () => { it('should send state event', async () => { - const preventDefault = vi.fn(); const stateEvent = { hello: 'world' }; - - matrixWidgetApi.sendStateEvent.mockResolvedValue({ + matrixWidgetApi.sendStateEvent.mockResolvedValueOnce({ event_id: '$event-id', room_id: '!current-room', }); - matrixWidgetApi.on.mockImplementationOnce((_, listener) => { - setTimeout(() => { - listener({ - detail: { - data: mockRoomEvent({ - state_key: '', - content: stateEvent, - }), - }, - preventDefault, - }); - }); - - return matrixWidgetApi; - }); - matrixWidgetApi.off.mockReturnThis(); await expect( widgetApi.sendStateEvent('com.example.test', stateEvent), ).resolves.toMatchObject({ + event_id: '$event-id', room_id: '!current-room', - sender: '@my-user-id', - state_key: '', - type: 'com.example.test', - content: stateEvent, }); - expect(matrixWidgetApi.on).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), - ); - expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalled(); - expect(matrixWidgetApi.off).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), + expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalledWith( + 'com.example.test', + '', + stateEvent, + undefined, ); - expect(preventDefault).toHaveBeenCalled(); - expect(matrixWidgetApi.transport.reply).toHaveBeenCalled(); }); it('should send state event with custom state key', async () => { - const preventDefault = vi.fn(); const stateEvent = { hello: 'world' }; - matrixWidgetApi.sendStateEvent.mockResolvedValue({ + matrixWidgetApi.sendStateEvent.mockResolvedValueOnce({ room_id: '!current-room', event_id: '$event-id', }); - matrixWidgetApi.on.mockImplementationOnce((_, listener) => { - setTimeout(() => { - listener({ - detail: { - data: mockRoomEvent({ - content: stateEvent, - state_key: 'custom-state-key', - }), - }, - preventDefault, - }); - }); - - return matrixWidgetApi; - }); - matrixWidgetApi.off.mockReturnThis(); await expect( widgetApi.sendStateEvent('com.example.test', stateEvent, { @@ -1137,49 +1094,23 @@ describe('WidgetApiImpl', () => { }), ).resolves.toMatchObject({ room_id: '!current-room', - sender: '@my-user-id', - state_key: 'custom-state-key', - type: 'com.example.test', - content: stateEvent, + event_id: '$event-id', }); - expect(matrixWidgetApi.on).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), - ); - expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalled(); - expect(matrixWidgetApi.off).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), + expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalledWith( + 'com.example.test', + 'custom-state-key', + stateEvent, + undefined, ); - expect(preventDefault).toHaveBeenCalled(); - expect(matrixWidgetApi.transport.reply).toHaveBeenCalled(); }); it('should send state event to another room', async () => { - const preventDefault = vi.fn(); const stateEvent = { hello: 'world' }; - matrixWidgetApi.sendStateEvent.mockResolvedValue({ + matrixWidgetApi.sendStateEvent.mockResolvedValueOnce({ room_id: '!another-room', event_id: '$event-id', }); - matrixWidgetApi.on.mockImplementationOnce((_, listener) => { - setTimeout(() => { - listener({ - detail: { - data: mockRoomEvent({ - state_key: '', - room_id: '!another-room', - content: stateEvent, - }), - }, - preventDefault, - }); - }); - - return matrixWidgetApi; - }); - matrixWidgetApi.off.mockReturnThis(); await expect( widgetApi.sendStateEvent('com.example.test', stateEvent, { @@ -1187,22 +1118,14 @@ describe('WidgetApiImpl', () => { }), ).resolves.toMatchObject({ room_id: '!another-room', - sender: '@my-user-id', - state_key: '', - type: 'com.example.test', - content: stateEvent, + event_id: '$event-id', }); - expect(matrixWidgetApi.on).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), - ); - expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalled(); - expect(matrixWidgetApi.off).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), + expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalledWith( + 'com.example.test', + '', + stateEvent, + '!another-room', ); - expect(preventDefault).toHaveBeenCalled(); - expect(matrixWidgetApi.transport.reply).toHaveBeenCalled(); }); it('should reject on error while sending', async () => { @@ -1211,20 +1134,15 @@ describe('WidgetApiImpl', () => { matrixWidgetApi.sendStateEvent.mockRejectedValue( new Error('Power to low'), ); - matrixWidgetApi.on.mockReturnThis(); - matrixWidgetApi.off.mockReturnThis(); await expect(() => widgetApi.sendStateEvent('com.example.test', stateEvent), ).rejects.toThrow('Power to low'); - expect(matrixWidgetApi.on).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), - ); - expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalled(); - expect(matrixWidgetApi.off).toHaveBeenCalledWith( - 'action:send_event', - expect.any(Function), + expect(matrixWidgetApi.sendStateEvent).toHaveBeenCalledWith( + 'com.example.test', + '', + stateEvent, + undefined, ); }); }); diff --git a/packages/api/src/api/WidgetApiImpl.ts b/packages/api/src/api/WidgetApiImpl.ts index 2cdec38c..427d01f7 100644 --- a/packages/api/src/api/WidgetApiImpl.ts +++ b/packages/api/src/api/WidgetApiImpl.ts @@ -24,6 +24,7 @@ import { INotifyCapabilitiesActionRequest, IOpenIDCredentials, IRoomEvent, + ISendEventFromWidgetResponseData, IUploadFileActionFromWidgetResponseData, IWidgetApiRequest, IWidgetApiRequestData, @@ -419,40 +420,17 @@ export class WidgetApiImpl implements WidgetApi { } /** {@inheritDoc WidgetApi.sendStateEvent} */ - async sendStateEvent( + sendStateEvent( eventType: string, content: T, { roomId, stateKey = '' }: { roomId?: string; stateKey?: string } = {}, - ): Promise> { - const subject = new ReplaySubject>(); - const subscription = this.events$.subscribe((e) => subject.next(e)); - - try { - const { event_id, room_id } = await this.matrixWidgetApi.sendStateEvent( - eventType, - stateKey, - content, - roomId, - ); - // TODO: Why do we even return the event, not just the event id, we never - // need it. - const event = await firstValueFrom( - subject.pipe( - filter((event) => { - const matrixEvent = event.detail.data as unknown as IRoomEvent; - - return ( - matrixEvent.event_id === event_id && - matrixEvent.room_id === room_id - ); - }), - map((event) => event.detail.data as StateEvent), - ), - ); - return event; - } finally { - subscription.unsubscribe(); - } + ): Promise { + return this.matrixWidgetApi.sendStateEvent( + eventType, + stateKey, + content, + roomId, + ); } /** {@inheritDoc WidgetApi.receiveRoomEvents} */ diff --git a/packages/api/src/api/types.ts b/packages/api/src/api/types.ts index e21c3384..2ac92e6b 100644 --- a/packages/api/src/api/types.ts +++ b/packages/api/src/api/types.ts @@ -23,6 +23,7 @@ import { IModalWidgetReturnData, IOpenIDCredentials, IRoomEvent, + ISendEventFromWidgetResponseData, IUploadFileActionFromWidgetResponseData, IWidget, IWidgetApiRequest, @@ -308,7 +309,7 @@ export type WidgetApi = { eventType: string, content: T, options?: { roomId?: string; stateKey?: string }, - ): Promise>; + ): Promise; /** * Receive all room events of a given type from the current room. diff --git a/packages/testing/src/api/mockWidgetApi.ts b/packages/testing/src/api/mockWidgetApi.ts index fbe382c7..ee58dd5c 100644 --- a/packages/testing/src/api/mockWidgetApi.ts +++ b/packages/testing/src/api/mockWidgetApi.ts @@ -211,7 +211,6 @@ export function mockWidgetApi(opts?: { observeStateEvents: vi.fn(), // @ts-expect-error -- Mocks are expected to return no proper T type observeRoomEvents: vi.fn(), - // @ts-expect-error -- Mocks are expected to return no proper T type sendStateEvent: vi.fn(), // @ts-expect-error -- Mocks are expected to return no proper T type sendRoomEvent: vi.fn(), From bccf81203187fec7a265ec21a02a637f057230ae Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 3 Dec 2024 12:53:41 +0100 Subject: [PATCH 2/2] Add state event utils and expose them via the utils module Signed-off-by: Michael Weimann --- .changeset/fresh-peaches-smell.md | 5 +++ packages/api/api-report.api.md | 6 ++++ packages/api/src/api/index.ts | 4 +++ packages/api/src/api/utils.ts | 55 +++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 .changeset/fresh-peaches-smell.md diff --git a/.changeset/fresh-peaches-smell.md b/.changeset/fresh-peaches-smell.md new file mode 100644 index 00000000..ccf81acd --- /dev/null +++ b/.changeset/fresh-peaches-smell.md @@ -0,0 +1,5 @@ +--- +'@matrix-widget-toolkit/api': minor +--- + +The api package now exposes some utility functions via the `utils` module diff --git a/packages/api/api-report.api.md b/packages/api/api-report.api.md index f116b3b2..50fdbc3e 100644 --- a/packages/api/api-report.api.md +++ b/packages/api/api-report.api.md @@ -92,6 +92,9 @@ export function isValidRedactionEvent(event: RoomEvent): event is Redac // @public export function isValidRoomMemberStateEvent(event: StateEvent): event is StateEvent; +// @public +export function makeEventFromSendStateEventResult(type: string, stateKey: string, content: T, sender: string, sendResult: ISendEventFromWidgetResponseData): StateEvent; + // @public export type MembershipState = 'join' | 'invite' | 'leave' | 'ban' | 'knock'; @@ -172,6 +175,9 @@ export type RoomMemberStateEventContent = { avatar_url?: string | null; }; +// @public +export function sendStateEventWithEventResult(widgetApi: WidgetApi, type: string, stateKey: string, content: T): Promise>; + // @public export const STATE_EVENT_POWER_LEVELS = "m.room.power_levels"; diff --git a/packages/api/src/api/index.ts b/packages/api/src/api/index.ts index 78507434..4d4cbd19 100644 --- a/packages/api/src/api/index.ts +++ b/packages/api/src/api/index.ts @@ -37,5 +37,9 @@ export type { WidgetParameters, WidgetRegistration, } from './types'; +export { + makeEventFromSendStateEventResult, + sendStateEventWithEventResult, +} from './utils'; export { WidgetApiImpl } from './WidgetApiImpl'; export type { WidgetApiOptions } from './WidgetApiImpl'; diff --git a/packages/api/src/api/utils.ts b/packages/api/src/api/utils.ts index a09f282d..8744b825 100644 --- a/packages/api/src/api/utils.ts +++ b/packages/api/src/api/utils.ts @@ -17,9 +17,11 @@ import { Capability, IRoomEvent, + ISendEventFromWidgetResponseData, Symbols, WidgetEventCapability, } from 'matrix-widget-api'; +import { StateEvent, WidgetApi } from './types'; export function convertToRawCapabilities( rawCapabilities: Array, @@ -72,3 +74,56 @@ export function isInRoom( return roomIds.includes(matrixEvent.room_id); } + +/** + * Create a state event from the arguments. + * + * @returns A state event with current timestamp origin_server_ts. + */ +export function makeEventFromSendStateEventResult( + type: string, + stateKey: string, + content: T, + sender: string, + sendResult: ISendEventFromWidgetResponseData, +): StateEvent { + if (sendResult.event_id === undefined) { + throw new Error('Send state event did not return an event ID'); + } + + return { + content, + event_id: sendResult.event_id, + origin_server_ts: Date.now(), + room_id: sendResult.room_id, + sender, + state_key: stateKey, + type, + }; +} + +/** + * Send a state event and resolve to a "virtual" state event. + * + * @returns Promise, that resolves to a state event with current timestamp origin_server_ts. + */ +export async function sendStateEventWithEventResult( + widgetApi: WidgetApi, + type: string, + stateKey: string, + content: T, +): Promise> { + if (widgetApi.widgetParameters.userId === undefined) { + throw new Error('Own user ID is undefined'); + } + + const response = await widgetApi.sendStateEvent(type, content, { stateKey }); + + return makeEventFromSendStateEventResult( + type, + stateKey, + content, + widgetApi.widgetParameters.userId, + response, + ); +}