From 68bc427c55f21d216d2f2820e21103a0fe4c8e13 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Tue, 12 Nov 2024 00:44:12 -0100 Subject: [PATCH] Initial draft Signed-off-by: Milton Moura --- .../src/widgetCapabilities.ts | 11 ++ .../components/Whiteboard/WhiteboardHost.tsx | 18 +++ .../src/lib/testUtils/documentTestUtils.tsx | 1 + .../src/lib/testUtils/matrixTestUtils.ts | 54 +++++++ packages/react-sdk/src/model/index.ts | 10 ++ .../src/model/roomEncryptionEvent.test.ts | 74 ++++++++++ .../src/model/roomEncryptionEvent.ts | 40 ++++++ .../model/roomHistoryVisibilityEvent.test.ts | 77 ++++++++++ .../src/model/roomHistoryVisibilityEvent.ts | 43 ++++++ .../src/state/presentationManagerImpl.test.ts | 1 + .../src/state/synchronizedDocumentImpl.ts | 20 +++ packages/react-sdk/src/state/types.ts | 9 +- .../src/state/whiteboardInstanceImpl.test.ts | 1 + .../src/state/whiteboardInstanceImpl.ts | 9 ++ .../src/store/api/roomEncryptionApi.test.ts | 91 ++++++++++++ .../src/store/api/roomEncryptionApi.ts | 91 ++++++++++++ .../api/roomHistoryVisibilityApi.test.ts | 134 ++++++++++++++++++ .../src/store/api/roomHistoryVisibilityApi.ts | 93 ++++++++++++ 18 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 packages/react-sdk/src/model/roomEncryptionEvent.test.ts create mode 100644 packages/react-sdk/src/model/roomEncryptionEvent.ts create mode 100644 packages/react-sdk/src/model/roomHistoryVisibilityEvent.test.ts create mode 100644 packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts create mode 100644 packages/react-sdk/src/store/api/roomEncryptionApi.test.ts create mode 100644 packages/react-sdk/src/store/api/roomEncryptionApi.ts create mode 100644 packages/react-sdk/src/store/api/roomHistoryVisibilityApi.test.ts create mode 100644 packages/react-sdk/src/store/api/roomHistoryVisibilityApi.ts diff --git a/matrix-neoboard-widget/src/widgetCapabilities.ts b/matrix-neoboard-widget/src/widgetCapabilities.ts index 02d24fc1f..bb909ba94 100644 --- a/matrix-neoboard-widget/src/widgetCapabilities.ts +++ b/matrix-neoboard-widget/src/widgetCapabilities.ts @@ -23,6 +23,8 @@ import { ROOM_EVENT_DOCUMENT_CHUNK, ROOM_EVENT_DOCUMENT_CREATE, ROOM_EVENT_DOCUMENT_SNAPSHOT, + STATE_EVENT_ROOM_ENCRYPTION, + STATE_EVENT_ROOM_HISTORY_VISIBILITY, STATE_EVENT_ROOM_NAME, STATE_EVENT_WHITEBOARD, STATE_EVENT_WHITEBOARD_SESSIONS, @@ -102,6 +104,15 @@ export const widgetCapabilities = [ STATE_EVENT_ROOM_NAME, ), + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + STATE_EVENT_ROOM_HISTORY_VISIBILITY, + ), + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + STATE_EVENT_ROOM_ENCRYPTION, + ), + WidgetEventCapability.forToDeviceEvent( EventDirection.Send, TO_DEVICE_MESSAGE_CONNECTION_SIGNALING, diff --git a/packages/react-sdk/src/components/Whiteboard/WhiteboardHost.tsx b/packages/react-sdk/src/components/Whiteboard/WhiteboardHost.tsx index e527ace0f..fe2b9fc89 100644 --- a/packages/react-sdk/src/components/Whiteboard/WhiteboardHost.tsx +++ b/packages/react-sdk/src/components/Whiteboard/WhiteboardHost.tsx @@ -63,7 +63,25 @@ const WhiteboardHost = ({ useLayoutState(); const { activeElementIds } = useActiveElements(); const overrides = useElementOverrides(activeElementIds); + /* + const { data: roomMembers } = useGetRoomMembersQuery(); + const whiteboardInstance = useActiveWhiteboardInstance(); + useEffect(() => { + const members = roomMembers?.entities; + if (members) { + const invitesOrJoins = Object.values(members).filter((member) => { + return member.content.membership == 'invite' || member.content.membership == 'join'; + }); + const lastInviteOrJoin = invitesOrJoins.sort((a, b) => { + return b.origin_server_ts - a.origin_server_ts; + }); + console.log("MGCM: lastInviteOrJoin:", lastInviteOrJoin[0]); + + whiteboardInstance.persistIfNecessary(lastInviteOrJoin[0].origin_server_ts); + } + }, [roomMembers, whiteboardInstance]); + */ return ( of(false), destroy: () => {}, persist: () => Promise.resolve(), + getLatestDocumentSnapshot: () => undefined, }; const whiteboardInstance = new WhiteboardInstanceImpl( diff --git a/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts b/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts index f8764198f..1f56d9e15 100644 --- a/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts +++ b/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts @@ -28,6 +28,8 @@ import { DocumentChunk, DocumentCreate, DocumentSnapshot, + RoomEncryptionEvent, + RoomHistoryVisibilityEvent, RoomNameEvent, Whiteboard, WhiteboardSession, @@ -96,6 +98,58 @@ export function mockPowerLevelsEvent({ }; } +/** + * Create a matrix room encryption event with known test data. + * + * @remarks Only use for tests + */ +export function mockRoomEncryption({ + room_id = '!room-id', + content = {}, +}: { + room_id?: string; + content?: Partial; +} = {}): StateEvent { + return { + type: 'm.room.encryption', + sender: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + ...content, + }, + state_key: room_id, + origin_server_ts: 0, + event_id: '$event-id-0', + room_id, + }; +} + +/** + * Create a matrix room history visibility event with known test data. + * + * @remarks Only use for tests + */ +export function mockRoomHistoryVisibility({ + room_id = '!room-id', + content = {}, +}: { + room_id?: string; + content?: Partial; +} = {}): StateEvent { + return { + type: 'm.room.history_visibility', + sender: '', + content: { + history_visibility: 'shared', + ...content, + }, + state_key: room_id, + origin_server_ts: 0, + event_id: '$event-id-0', + room_id, + }; +} + /** * Create a matrix room name event with known test data. * diff --git a/packages/react-sdk/src/model/index.ts b/packages/react-sdk/src/model/index.ts index c6f72086f..c814c496a 100644 --- a/packages/react-sdk/src/model/index.ts +++ b/packages/react-sdk/src/model/index.ts @@ -35,6 +35,16 @@ export { isValidDocumentSnapshotRoomEvent, } from './documentSnapshot'; export type { DocumentSnapshot } from './documentSnapshot'; +export { + STATE_EVENT_ROOM_ENCRYPTION, + isValidRoomEncryptionEvent, +} from './roomEncryptionEvent'; +export type { RoomEncryptionEvent } from './roomEncryptionEvent'; +export { + STATE_EVENT_ROOM_HISTORY_VISIBILITY, + isValidRoomHistoryVisibilityEvent, +} from './roomHistoryVisibilityEvent'; +export type { RoomHistoryVisibilityEvent } from './roomHistoryVisibilityEvent'; export { STATE_EVENT_ROOM_NAME, isValidRoomNameEvent } from './roomNameEvent'; export type { RoomNameEvent } from './roomNameEvent'; export { diff --git a/packages/react-sdk/src/model/roomEncryptionEvent.test.ts b/packages/react-sdk/src/model/roomEncryptionEvent.test.ts new file mode 100644 index 000000000..cd835b606 --- /dev/null +++ b/packages/react-sdk/src/model/roomEncryptionEvent.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { isValidRoomEncryptionEvent } from './roomEncryptionEvent'; + +describe('is valid event', () => { + it('should accept event', () => { + expect( + isValidRoomEncryptionEvent({ + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.encryption', + }), + ).toBe(true); + }); + + it('should accept additional properties', () => { + expect( + isValidRoomEncryptionEvent({ + content: { + algorithm: 'm.megolm.v1.aes-sha2', + rotation_period_ms: 604800000, + rotation_period_msgs: 100, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.encryption', + }), + ).toBe(true); + }); + + it.each([{ algorithm: undefined }, { algorithm: null }])( + 'should reject event with patch %j', + (patch: object) => { + expect( + isValidRoomEncryptionEvent({ + content: { + algorithm: 'm.megolm.v1.aes-sha2', + ...patch, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.encryption', + }), + ).toBe(false); + }, + ); +}); diff --git a/packages/react-sdk/src/model/roomEncryptionEvent.ts b/packages/react-sdk/src/model/roomEncryptionEvent.ts new file mode 100644 index 000000000..8aba04893 --- /dev/null +++ b/packages/react-sdk/src/model/roomEncryptionEvent.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StateEvent } from '@matrix-widget-toolkit/api'; +import Joi from 'joi'; +import { isValidEvent } from './validation'; + +export const STATE_EVENT_ROOM_ENCRYPTION = 'm.room.encryption'; + +export type RoomEncryptionEvent = { + algorithm: string; +}; + +// based on https://github.com/matrix-org/matrix-spec/blob/03cdea4b57320926a6da73ad3b3f6c7f4fd0a7c2/data/event-schemas/schema/m.room.encryption.yaml +const roomEncryptionEventSchema = Joi.object({ + algorithm: Joi.string().required(), +}).unknown(); + +export function isValidRoomEncryptionEvent( + event: StateEvent, +): event is StateEvent { + return isValidEvent( + event, + STATE_EVENT_ROOM_ENCRYPTION, + roomEncryptionEventSchema, + ); +} diff --git a/packages/react-sdk/src/model/roomHistoryVisibilityEvent.test.ts b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.test.ts new file mode 100644 index 000000000..a81a06a70 --- /dev/null +++ b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { isValidRoomHistoryVisibilityEvent } from './roomHistoryVisibilityEvent'; + +describe('is valid event', () => { + it('should accept event', () => { + expect( + isValidRoomHistoryVisibilityEvent({ + content: { + history_visibility: 'shared', + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.history_visibility', + }), + ).toBe(true); + }); + + it.each([ + { history_visibility: 'invited' }, + { history_visibility: 'joined' }, + { history_visibility: 'world_readable' }, + ])('should accept event with patch %j', (patch: object) => { + expect( + isValidRoomHistoryVisibilityEvent({ + content: { + history_visibility: 'shared', + ...patch, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.history_visibility', + }), + ).toBe(true); + }); + + it.each([ + { history_visibility: undefined }, + { history_visibility: null }, + ])('should reject event with patch %j', (patch: object) => { + expect( + isValidRoomHistoryVisibilityEvent({ + content: { + history_visibility: 'shared', + ...patch, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id', + state_key: '', + sender: '@user-id', + type: 'm.room.history_visibility', + }), + ).toBe(false); + }); +}); diff --git a/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts new file mode 100644 index 000000000..95d2b9f5b --- /dev/null +++ b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StateEvent } from '@matrix-widget-toolkit/api'; +import Joi from 'joi'; +import { isValidEvent } from './validation'; + +export const STATE_EVENT_ROOM_HISTORY_VISIBILITY = 'm.room.history_visibility'; + +export type RoomHistoryVisibilityEvent = { + history_visibility: string; +}; + +// based on https://github.com/matrix-org/matrix-spec/blob/03cdea4b57320926a6da73ad3b3f6c7f4fd0a7c2/data/event-schemas/schema/m.room.history_visibility.yaml +const roomHistoryVisibilityEventSchema = Joi.object< + RoomHistoryVisibilityEvent, + true +>({ + history_visibility: Joi.string().required(), +}).unknown(); + +export function isValidRoomHistoryVisibilityEvent( + event: StateEvent, +): event is StateEvent { + return isValidEvent( + event, + STATE_EVENT_ROOM_HISTORY_VISIBILITY, + roomHistoryVisibilityEventSchema, + ); +} diff --git a/packages/react-sdk/src/state/presentationManagerImpl.test.ts b/packages/react-sdk/src/state/presentationManagerImpl.test.ts index 82b6792c0..365329d01 100644 --- a/packages/react-sdk/src/state/presentationManagerImpl.test.ts +++ b/packages/react-sdk/src/state/presentationManagerImpl.test.ts @@ -99,6 +99,7 @@ describe('presentationManager', () => { undo: vi.fn(), destroy: vi.fn(), persist: vi.fn(), + persistIfNecessary: vi.fn(), }; enableObserveVisibilityStateSubject = new Subject(); diff --git a/packages/react-sdk/src/state/synchronizedDocumentImpl.ts b/packages/react-sdk/src/state/synchronizedDocumentImpl.ts index 2c5948dc5..2bb6aae15 100644 --- a/packages/react-sdk/src/state/synchronizedDocumentImpl.ts +++ b/packages/react-sdk/src/state/synchronizedDocumentImpl.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { RoomEvent } from '@matrix-widget-toolkit/api'; import { Base64 } from 'js-base64'; import { clone } from 'lodash'; import { getLogger } from 'loglevel'; @@ -34,6 +35,7 @@ import { throttleTime, } from 'rxjs'; import { isDefined } from '../lib'; +import { DocumentSnapshot } from '../model'; import { documentSnapshotApi, StoreType } from '../store'; import { DocumentSnapshotValidator } from '../store/api/documentSnapshotBacklog'; import { @@ -207,6 +209,24 @@ export class SynchronizedDocumentImpl> await this.persistDocument(this.document); } + getLatestDocumentSnapshot(): RoomEvent | undefined { + const state = this.store.getState(); + const documentId = this.documentId; + + const result = documentSnapshotApi.endpoints.getDocumentSnapshot.select({ + documentId, + validator: undefined, + })(state); + + if (!result.isLoading) { + const snapshotData = result.data; + + if (snapshotData) { + return snapshotData.event; + } + } + } + private async createDocumentSnapshot( documentId: string, data: Uint8Array, diff --git a/packages/react-sdk/src/state/types.ts b/packages/react-sdk/src/state/types.ts index 372cd69e4..a40103b5a 100644 --- a/packages/react-sdk/src/state/types.ts +++ b/packages/react-sdk/src/state/types.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; +import { RoomEvent, StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; import { Observable } from 'rxjs'; -import { Whiteboard } from '../model'; +import { DocumentSnapshot, Whiteboard } from '../model'; import { CommunicationChannelStatistics } from './communication'; import { Document, @@ -145,6 +145,9 @@ export type WhiteboardInstance = { /** Persist the whiteboard state. */ persist(): Promise; + + /** Persist under special conditions */ + persistIfNecessary(timestamp: number): void; }; export type ElementUpdate = { @@ -258,6 +261,8 @@ export type SynchronizedDocument> = { destroy(): void; /** Persist the document immediately. */ persist(): Promise; + /** Get the latest document snapshot, if available */ + getLatestDocumentSnapshot(): RoomEvent | undefined; }; export type PresentationState = diff --git a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts index 2eb0a8696..de6897e4e 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts @@ -81,6 +81,7 @@ describe('WhiteboardInstanceImpl', () => { .mockReturnValue(observeDocumentStatisticsSubject), observeIsLoading: vi.fn().mockReturnValue(observeIsLoadingSubject), persist: vi.fn(), + getLatestDocumentSnapshot: vi.fn(), }; }); diff --git a/packages/react-sdk/src/state/whiteboardInstanceImpl.ts b/packages/react-sdk/src/state/whiteboardInstanceImpl.ts index 959addfc8..91471c848 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.ts @@ -456,6 +456,15 @@ export class WhiteboardInstanceImpl implements WhiteboardInstance { await this.synchronizedDocument.persist(); } + async persistIfNecessary(timestamp: number) { + const snapshot = this.synchronizedDocument.getLatestDocumentSnapshot(); + console.log('MGCM: snapshot:', snapshot); + if (snapshot && snapshot.origin_server_ts < timestamp) { + console.log('MGCM: !!persisting!!'); + await this.persist(); + } + } + clearUndoManager(): void { this.synchronizedDocument.getDocument().getUndoManager().clear(); } diff --git a/packages/react-sdk/src/store/api/roomEncryptionApi.test.ts b/packages/react-sdk/src/store/api/roomEncryptionApi.test.ts new file mode 100644 index 000000000..d0cf4ea94 --- /dev/null +++ b/packages/react-sdk/src/store/api/roomEncryptionApi.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; +import { waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mockRoomEncryption } from '../../lib/testUtils/matrixTestUtils'; +import { createStore } from '../store'; +import { roomEncryptionApi } from './roomEncryptionApi'; + +let widgetApi: MockedWidgetApi; + +afterEach(() => widgetApi.stop()); + +beforeEach(() => { + widgetApi = mockWidgetApi(); +}); + +describe('getRoomEncryption', () => { + it('should return room encryption state', async () => { + const event = widgetApi.mockSendStateEvent(mockRoomEncryption()); + + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch(roomEncryptionApi.endpoints.getRoomEncryption.initiate()) + .unwrap(), + ).resolves.toEqual({ event }); + }); + + it('should handle missing encryption state', async () => { + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch(roomEncryptionApi.endpoints.getRoomEncryption.initiate()) + .unwrap(), + ).resolves.toEqual({ event: undefined }); + }); + + it('should handle load errors', async () => { + widgetApi.receiveStateEvents.mockRejectedValue(new Error('Some Error')); + + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch(roomEncryptionApi.endpoints.getRoomEncryption.initiate()) + .unwrap(), + ).rejects.toEqual({ + message: 'Could not load room encryption state: Some Error', + name: 'LoadFailed', + }); + }); + + it('should observe room encryption state', async () => { + const store = createStore({ widgetApi }); + + store.dispatch(roomEncryptionApi.endpoints.getRoomEncryption.initiate()); + + await waitFor(() => + expect( + roomEncryptionApi.endpoints.getRoomEncryption.select()(store.getState()) + .data, + ).toEqual({ event: undefined }), + ); + + const event = widgetApi.mockSendStateEvent(mockRoomEncryption()); + + await waitFor(() => + expect( + roomEncryptionApi.endpoints.getRoomEncryption.select()(store.getState()) + .data, + ).toEqual({ event }), + ); + }); +}); diff --git a/packages/react-sdk/src/store/api/roomEncryptionApi.ts b/packages/react-sdk/src/store/api/roomEncryptionApi.ts new file mode 100644 index 000000000..b1ed158c9 --- /dev/null +++ b/packages/react-sdk/src/store/api/roomEncryptionApi.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StateEvent } from '@matrix-widget-toolkit/api'; +import { first, isError } from 'lodash'; +import { filter } from 'rxjs'; +import { + RoomEncryptionEvent, + STATE_EVENT_ROOM_ENCRYPTION, + isValidRoomEncryptionEvent, +} from '../../model'; +import { ThunkExtraArgument } from '../store'; +import { baseApi } from './baseApi'; + +/** + * Endpoints to read the current room encryption state. + * + * @remarks this api extends the {@link baseApi} so it should + * not be registered at the store. + */ +export const roomEncryptionApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + /** + * Return the room encryption event of a room. + */ + getRoomEncryption: builder.query< + { event: StateEvent | undefined }, + void + >({ + queryFn: async (_, { extra }) => { + const widgetApi = await (extra as ThunkExtraArgument).widgetApi; + + try { + const events = await widgetApi.receiveStateEvents( + STATE_EVENT_ROOM_ENCRYPTION, + ); + + return { + data: { event: first(events.filter(isValidRoomEncryptionEvent)) }, + }; + } catch (e) { + return { + error: { + name: 'LoadFailed', + message: `Could not load room encryption state: ${ + isError(e) ? e.message : e + }`, + }, + }; + } + }, + + async onCacheEntryAdded( + _, + { cacheDataLoaded, cacheEntryRemoved, extra, updateCachedData }, + ) { + const widgetApi = await (extra as ThunkExtraArgument).widgetApi; + + // wait until first data is cached + await cacheDataLoaded; + + const subscription = widgetApi + .observeStateEvents(STATE_EVENT_ROOM_ENCRYPTION) + .pipe(filter(isValidRoomEncryptionEvent)) + .subscribe((event) => { + updateCachedData(() => ({ event })); + }); + + // wait until subscription is cancelled + await cacheEntryRemoved; + + subscription.unsubscribe(); + }, + }), + }), +}); + +export const { useGetRoomEncryptionQuery } = roomEncryptionApi; diff --git a/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.test.ts b/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.test.ts new file mode 100644 index 000000000..9bdb8e8d1 --- /dev/null +++ b/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; +import { waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mockRoomHistoryVisibility } from '../../lib/testUtils/matrixTestUtils'; +import { createStore } from '../store'; +import { roomHistoryVisibilityApi } from './roomHistoryVisibilityApi'; + +let widgetApi: MockedWidgetApi; + +afterEach(() => widgetApi.stop()); + +beforeEach(() => { + widgetApi = mockWidgetApi(); +}); + +describe('getRoomHistoryVisibility', () => { + it('should return room history visibility', async () => { + const event = widgetApi.mockSendStateEvent(mockRoomHistoryVisibility()); + + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.initiate(), + ) + .unwrap(), + ).resolves.toEqual({ event }); + }); + + it('should handle missing history visibility', async () => { + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.initiate(), + ) + .unwrap(), + ).resolves.toEqual({ event: undefined }); + }); + + it('should handle load errors', async () => { + widgetApi.receiveStateEvents.mockRejectedValue(new Error('Some Error')); + + const store = createStore({ widgetApi }); + + await expect( + store + .dispatch( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.initiate(), + ) + .unwrap(), + ).rejects.toEqual({ + message: 'Could not load room history visibility: Some Error', + name: 'LoadFailed', + }); + }); + + it('should observe room history visibility', async () => { + const store = createStore({ widgetApi }); + + store.dispatch( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.initiate(), + ); + + await waitFor(() => + expect( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.select()( + store.getState(), + ).data, + ).toEqual({ event: undefined }), + ); + + const event = widgetApi.mockSendStateEvent(mockRoomHistoryVisibility()); + + await waitFor(() => + expect( + roomHistoryVisibilityApi.endpoints.getRoomHistoryVisibility.select()( + store.getState(), + ).data, + ).toEqual({ event }), + ); + }); +}); diff --git a/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.ts b/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.ts new file mode 100644 index 000000000..777c0309e --- /dev/null +++ b/packages/react-sdk/src/store/api/roomHistoryVisibilityApi.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StateEvent } from '@matrix-widget-toolkit/api'; +import { first, isError } from 'lodash'; +import { filter } from 'rxjs'; +import { + RoomHistoryVisibilityEvent, + STATE_EVENT_ROOM_HISTORY_VISIBILITY, + isValidRoomHistoryVisibilityEvent, +} from '../../model'; +import { ThunkExtraArgument } from '../store'; +import { baseApi } from './baseApi'; + +/** + * Endpoints to read the current room history visibility state. + * + * @remarks this api extends the {@link baseApi} so it should + * not be registered at the store. + */ +export const roomHistoryVisibilityApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + /** + * Return the room encryption event of a room. + */ + getRoomHistoryVisibility: builder.query< + { event: StateEvent | undefined }, + void + >({ + queryFn: async (_, { extra }) => { + const widgetApi = await (extra as ThunkExtraArgument).widgetApi; + + try { + const events = await widgetApi.receiveStateEvents( + STATE_EVENT_ROOM_HISTORY_VISIBILITY, + ); + + return { + data: { + event: first(events.filter(isValidRoomHistoryVisibilityEvent)), + }, + }; + } catch (e) { + return { + error: { + name: 'LoadFailed', + message: `Could not load room history visibility: ${ + isError(e) ? e.message : e + }`, + }, + }; + } + }, + + async onCacheEntryAdded( + _, + { cacheDataLoaded, cacheEntryRemoved, extra, updateCachedData }, + ) { + const widgetApi = await (extra as ThunkExtraArgument).widgetApi; + + // wait until first data is cached + await cacheDataLoaded; + + const subscription = widgetApi + .observeStateEvents(STATE_EVENT_ROOM_HISTORY_VISIBILITY) + .pipe(filter(isValidRoomHistoryVisibilityEvent)) + .subscribe((event) => { + updateCachedData(() => ({ event })); + }); + + // wait until subscription is cancelled + await cacheEntryRemoved; + + subscription.unsubscribe(); + }, + }), + }), +}); + +export const { useGetRoomHistoryVisibilityQuery } = roomHistoryVisibilityApi;