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/.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..50fdbc3e 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'; @@ -91,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'; @@ -171,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"; @@ -222,7 +229,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 +351,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/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/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/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, + ); +} 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(),