From 3c3652c34fa17c9534af144fc2fc39c95d1dfbb5 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Tue, 19 Nov 2024 17:03:01 -0100 Subject: [PATCH] Refactoring based on review feedback Signed-off-by: Milton Moura --- packages/react-sdk/src/App.tsx | 89 +++++++++- .../FallbackSnapshotProvider/index.ts | 17 -- .../src/components/Layout/Layout.tsx | 90 +++++------ .../src/lib/testUtils/documentTestUtils.tsx | 1 - .../src/model/roomHistoryVisibilityEvent.ts | 4 +- .../src/state/presentationManagerImpl.test.ts | 1 + .../src/state/synchronizedDocumentImpl.ts | 22 +-- packages/react-sdk/src/state/types.ts | 15 +- .../usePersistOnJoinOrInvite.ts} | 141 ++++++++-------- .../usePersistOnJoinorInvite.test.tsx} | 153 ++++++++++++------ .../src/state/whiteboardInstanceImpl.test.ts | 1 - .../src/state/whiteboardInstanceImpl.ts | 26 +-- .../src/store/api/documentSnapshotApi.ts | 3 +- packages/react-sdk/src/store/api/index.ts | 1 + 14 files changed, 326 insertions(+), 238 deletions(-) delete mode 100644 packages/react-sdk/src/components/FallbackSnapshotProvider/index.ts rename packages/react-sdk/src/{components/FallbackSnapshotProvider/FallbackSnapshotProvider.tsx => state/usePersistOnJoinOrInvite.ts} (54%) rename packages/react-sdk/src/{components/FallbackSnapshotProvider/FallbackSnapshotProvider.test.tsx => state/usePersistOnJoinorInvite.test.tsx} (52%) diff --git a/packages/react-sdk/src/App.tsx b/packages/react-sdk/src/App.tsx index 0da61f2b..3993528e 100644 --- a/packages/react-sdk/src/App.tsx +++ b/packages/react-sdk/src/App.tsx @@ -15,20 +15,105 @@ */ import { useWidgetApi } from '@matrix-widget-toolkit/react'; +import { getLogger } from 'loglevel'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Layout, LayoutProps } from './components/Layout'; import { PageLoader } from './components/common/PageLoader'; -import { useOwnedWhiteboard, useWhiteboardManager } from './state'; +import { + isValidWhiteboardDocumentSnapshot, + useActiveWhiteboardInstance, + useOwnedWhiteboard, + useWhiteboardManager, +} from './state'; +import { + cancelableSnapshotTimer, + usePersistOnJoinOrInvite, +} from './state/usePersistOnJoinOrInvite'; +import { useGetDocumentSnapshotQuery } from './store/api'; export type AppProps = { layoutProps?: LayoutProps; }; +const logger = getLogger('App'); + export const App = ({ layoutProps }: AppProps) => { const { t } = useTranslation('neoboard'); const { value, loading } = useOwnedWhiteboard(); const whiteboardManager = useWhiteboardManager(); - const ownUserId = useWidgetApi().widgetParameters.userId; + const whiteboardInstance = useActiveWhiteboardInstance(); + const widgetApi = useWidgetApi(); + const ownUserId = widgetApi.widgetParameters.userId; + const { limitedHistoryVisibility, delayedPersist, lastMembershipEventTs } = + usePersistOnJoinOrInvite(); + const { data: latestSnapshot } = useGetDocumentSnapshotQuery({ + documentId: whiteboardInstance.getDocumentId(), + validator: isValidWhiteboardDocumentSnapshot, + }); + + const handlePersist = useCallback(() => { + if (latestSnapshot !== undefined && lastMembershipEventTs !== undefined) { + if (latestSnapshot.event.origin_server_ts < lastMembershipEventTs) { + logger.debug('Saving snapshot due to membership updates'); + whiteboardInstance.persist(true); + } + } + }, [latestSnapshot, lastMembershipEventTs, whiteboardInstance]); + + useEffect(() => { + // We don't need to do anything if we're not in a room with limited history visibility + // or the whiteboard is still loading + if (!limitedHistoryVisibility || whiteboardInstance.isLoading()) { + return; + } + + // We're in a room with limited history visibility, so we check + // if a snapshot is required after recent membership changes (invites+joins) + if (latestSnapshot !== undefined && lastMembershipEventTs !== undefined) { + // Is the snapshot outdated when compared to the last membership event? + if (latestSnapshot.event.origin_server_ts < lastMembershipEventTs) { + // We don't delay persisting if the membership event was sent by the current user + if (!delayedPersist) { + logger.debug( + 'Saving snapshot immediately due to current user sending the membership event', + ); + + whiteboardInstance.persist(true); + } else { + // Start a cancelable timer to persist the snapshot after a random delay + // If other clients run this earlier and send a snapshot, we don't need to persist + const delay = Math.floor(Math.random() * 20) + 10; + const timer = cancelableSnapshotTimer(handlePersist, delay * 1000); + logger.debug( + 'Will try to save a snapshot in ', + delay, + 's due to membership updates', + ); + + // cancel the timer if the component unmounts or the dependencies change + return () => { + logger.debug('Canceled delayed snapshot persistence timer'); + timer.cancel(); + }; + } + } else { + logger.debug( + 'We have a fresh snapshot after membership updates, no need to persist', + ); + } + } + return; + }, [ + delayedPersist, + handlePersist, + lastMembershipEventTs, + latestSnapshot, + limitedHistoryVisibility, + loading, + whiteboardInstance, + widgetApi, + ]); if (!ownUserId) { throw new Error('Unknown user id'); diff --git a/packages/react-sdk/src/components/FallbackSnapshotProvider/index.ts b/packages/react-sdk/src/components/FallbackSnapshotProvider/index.ts deleted file mode 100644 index d592ea74..00000000 --- a/packages/react-sdk/src/components/FallbackSnapshotProvider/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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. - */ - -export { FallbackSnapshotProvider } from './FallbackSnapshotProvider'; diff --git a/packages/react-sdk/src/components/Layout/Layout.tsx b/packages/react-sdk/src/components/Layout/Layout.tsx index 66babeec..81c4315c 100644 --- a/packages/react-sdk/src/components/Layout/Layout.tsx +++ b/packages/react-sdk/src/components/Layout/Layout.tsx @@ -29,7 +29,6 @@ import { BoardBar } from '../BoardBar'; import { CollaborationBar } from '../CollaborationBar'; import { DeveloperTools } from '../DeveloperTools/DeveloperTools'; import { ElementOverridesProvider } from '../ElementOverridesProvider'; -import { FallbackSnapshotProvider } from '../FallbackSnapshotProvider'; import { FullscreenModeBar } from '../FullscreenModeBar'; import { GuidedTour } from '../GuidedTour'; import { HelpCenterBar } from '../HelpCenterBar'; @@ -78,54 +77,49 @@ export function Layout({ height = '100vh' }: LayoutProps) { } return ( - - - - - - - + + + + + + - - - - - - {slideIds.map((slideId) => ( - - - - - {uploadDragOverlay} - - - - ))} - - - - - - - - - - + + + + + {slideIds.map((slideId) => ( + + + + + {uploadDragOverlay} + + + + ))} + + + + + + + + + ); } diff --git a/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx b/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx index 767ac111..e8b20974 100644 --- a/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx +++ b/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx @@ -130,7 +130,6 @@ export function mockWhiteboardManager( observeIsLoading: () => of(false), destroy: () => {}, persist: vi.fn().mockResolvedValue(undefined), - getLatestDocumentSnapshot: () => undefined, }; const whiteboardInstance = new WhiteboardInstanceImpl( diff --git a/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts index 95d2b9f5..23bc9dfa 100644 --- a/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts +++ b/packages/react-sdk/src/model/roomHistoryVisibilityEvent.ts @@ -29,7 +29,9 @@ const roomHistoryVisibilityEventSchema = Joi.object< RoomHistoryVisibilityEvent, true >({ - history_visibility: Joi.string().required(), + history_visibility: Joi.string() + .required() + .valid('invited', 'joined', 'shared', 'world_readable'), }).unknown(); export function isValidRoomHistoryVisibilityEvent( diff --git a/packages/react-sdk/src/state/presentationManagerImpl.test.ts b/packages/react-sdk/src/state/presentationManagerImpl.test.ts index 82b6792c..fe2ea543 100644 --- a/packages/react-sdk/src/state/presentationManagerImpl.test.ts +++ b/packages/react-sdk/src/state/presentationManagerImpl.test.ts @@ -80,6 +80,7 @@ describe('presentationManager', () => { getPresentationManager: vi.fn(), getSlide: vi.fn(), getSlideIds: vi.fn(), + getDocumentId: vi.fn(), getWhiteboardId: vi.fn(), getWhiteboardStatistics: vi.fn(), import: vi.fn(), diff --git a/packages/react-sdk/src/state/synchronizedDocumentImpl.ts b/packages/react-sdk/src/state/synchronizedDocumentImpl.ts index 03f79d08..09512ee0 100644 --- a/packages/react-sdk/src/state/synchronizedDocumentImpl.ts +++ b/packages/react-sdk/src/state/synchronizedDocumentImpl.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { RoomEvent } from '@matrix-widget-toolkit/api'; import { Base64 } from 'js-base64'; import { clone } from 'lodash'; import { getLogger } from 'loglevel'; @@ -35,7 +34,6 @@ import { throttleTime, } from 'rxjs'; import { isDefined } from '../lib'; -import { DocumentSnapshot } from '../model'; import { documentSnapshotApi, setSnapshotFailed, @@ -200,8 +198,8 @@ export class SynchronizedDocumentImpl> } private async persistDocument(doc: Document, force = false) { + // If there is no outstanding snapshot and we are not forcing to persist, we can skip this if (!this.statistics.snapshotOutstanding && !force) { - // No outstanding snapshot, do nothing return; } @@ -223,24 +221,6 @@ export class SynchronizedDocumentImpl> await this.persistDocument(this.document, force); } - getLatestDocumentSnapshot(): RoomEvent | undefined { - const state = this.store.getState(); - const documentId = this.documentId; - - const result = documentSnapshotApi.endpoints.getDocumentSnapshot.select({ - documentId, - validator: () => true, - })(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 8668d0b5..15781a28 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 { RoomEvent, StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; +import { StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; import { BehaviorSubject, Observable } from 'rxjs'; -import { DocumentSnapshot, Whiteboard } from '../model'; +import { Whiteboard } from '../model'; import { CommunicationChannelStatistics } from './communication'; import { Document, @@ -55,13 +55,10 @@ export type WhiteboardStatistics = { communicationChannel: CommunicationChannelStatistics; }; -export type PersistOptions = { - timestamp: number; - immediate: boolean; -}; - /** An instance of a whiteboard that can be used to read and manipulate it. */ export type WhiteboardInstance = { + /** Returns the id of the document */ + getDocumentId(): string; /** Returns the id of the whiteboard. */ getWhiteboardId(): string; /** @@ -153,7 +150,7 @@ export type WhiteboardInstance = { destroy(): void; /** Persist the whiteboard state. */ - persist(options?: PersistOptions): Promise; + persist(force?: boolean): Promise; }; export type ElementUpdate = { @@ -267,8 +264,6 @@ export type SynchronizedDocument> = { destroy(): void; /** Persist the document immediately. */ persist(force: boolean): Promise; - /** Get the latest document snapshot, if available */ - getLatestDocumentSnapshot(): RoomEvent | undefined; }; export type PresentationState = diff --git a/packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.tsx b/packages/react-sdk/src/state/usePersistOnJoinOrInvite.ts similarity index 54% rename from packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.tsx rename to packages/react-sdk/src/state/usePersistOnJoinOrInvite.ts index 0eb929c6..d5e61ff2 100644 --- a/packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.tsx +++ b/packages/react-sdk/src/state/usePersistOnJoinOrInvite.ts @@ -19,72 +19,91 @@ import { StateEvent, } from '@matrix-widget-toolkit/api'; import { useWidgetApi } from '@matrix-widget-toolkit/react'; -import { PropsWithChildren, useEffect } from 'react'; -import { RoomEncryptionEvent, RoomHistoryVisibilityEvent } from '../../model'; -import { useActiveWhiteboardInstance } from '../../state'; +import { RoomEncryptionEvent, RoomHistoryVisibilityEvent } from '../model'; +import { useActiveWhiteboardInstance } from '../state'; import { useGetRoomEncryptionQuery, useGetRoomHistoryVisibilityQuery, useGetRoomMembersQuery, -} from '../../store/api'; +} from '../store/api'; -export function FallbackSnapshotProvider({ children }: PropsWithChildren<{}>) { +type PersistOnJoinOrInviteResult = { + limitedHistoryVisibility: boolean; + delayedPersist: boolean; + lastMembershipEventTs?: number; +}; + +export function usePersistOnJoinOrInvite(): PersistOnJoinOrInviteResult { const whiteboardInstance = useActiveWhiteboardInstance(); const { data: roomMembers } = useGetRoomMembersQuery(); const { data: roomEncryption } = useGetRoomEncryptionQuery(); const { data: roomHistoryVisibility } = useGetRoomHistoryVisibilityQuery(); - const ownUserId = useWidgetApi().widgetParameters.userId; - - useEffect(() => { - const isRoomEncrypted = checkIfRoomEncrypted(roomEncryption); - const roomHistoryVisibilityValue = getRoomHistoryVisibilityValue( - roomHistoryVisibility, - ); - const isRoomHistoryVisibilityShared = checkIfRoomHistoryVisibilityShared( - roomHistoryVisibilityValue, - ); - const sendFallbackSnapshot = shouldSendFallbackSnapshot( - isRoomEncrypted, - isRoomHistoryVisibilityShared, - roomHistoryVisibilityValue, - ); - - if (sendFallbackSnapshot) { - const membershipFilter = getMembershipFilter(roomHistoryVisibilityValue); - const members = roomMembers?.entities; - - if (members) { - const invitesOrJoins = Object.values(members).filter(membershipFilter); - const sortedInvitesOrJoins = invitesOrJoins.sort((a, b) => { - return b.origin_server_ts - a.origin_server_ts; - }); - const lastInviteOrJoin = sortedInvitesOrJoins[0]; - - if ( - shouldUseInviteTimestamp(lastInviteOrJoin, roomHistoryVisibilityValue) - ) { - // TODO: Implement logic to get the timestamp of the invite event. - // There is currently no way to get an event by id or - // fetch an older state event from the room timeline using the Widget API - } - - const immediate = shouldPersistImmediately(lastInviteOrJoin, ownUserId); - - whiteboardInstance.persist({ - timestamp: lastInviteOrJoin.origin_server_ts, - immediate, - }); + const widgetApi = useWidgetApi(); + const ownUserId = widgetApi.widgetParameters.userId; + + if (whiteboardInstance.isLoading()) { + return { + limitedHistoryVisibility: false, + delayedPersist: false, + }; + } + + const isRoomEncrypted = checkIfRoomEncrypted(roomEncryption); + const roomHistoryVisibilityValue = getRoomHistoryVisibilityValue( + roomHistoryVisibility, + ); + const limitedHistoryVisibility = roomHasLimitedHistoryVisibility( + isRoomEncrypted, + roomHistoryVisibilityValue, + ); + + let delayedPersist = true; + let lastMembershipEventTs: number | undefined = undefined; + + if (limitedHistoryVisibility) { + const membershipFilter = getMembershipFilter(roomHistoryVisibilityValue); + const members = roomMembers?.entities; + + if (members) { + const invitesOrJoins = Object.values(members).filter(membershipFilter); + const sortedInvitesOrJoins = invitesOrJoins.sort((a, b) => { + return b.origin_server_ts - a.origin_server_ts; + }); + const lastInviteOrJoin = sortedInvitesOrJoins[0]; + + if (shouldUseInvite(lastInviteOrJoin, roomHistoryVisibilityValue)) { + // TODO: Implement logic to get the timestamp of the invite event. + // There is currently no way to get an event by id or + // fetch an older state event from the room timeline using the Widget API + // lastInviteOrJoin = getInviteEvent(lastInviteOrJoin); } + + delayedPersist = !shouldPersistImmediately(lastInviteOrJoin, ownUserId); + lastMembershipEventTs = lastInviteOrJoin.origin_server_ts; } - }, [ - roomMembers, - whiteboardInstance, - roomEncryption, - roomHistoryVisibility, - ownUserId, - ]); + } + + return { + limitedHistoryVisibility, + delayedPersist, + lastMembershipEventTs, + }; +} - return <>{children}; +export function cancelableSnapshotTimer( + callback: () => void, + delay: number, +): { cancel: () => void } { + let timerId: NodeJS.Timeout | null = setTimeout(callback, delay); + + return { + cancel: () => { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + }, + }; } function checkIfRoomEncrypted(roomEncryption?: { @@ -101,20 +120,14 @@ function getRoomHistoryVisibilityValue(roomHistoryVisibility?: { return roomHistoryVisibility?.event?.content?.history_visibility; } -function checkIfRoomHistoryVisibilityShared( - roomHistoryVisibilityValue: string | undefined, -): boolean { - return roomHistoryVisibilityValue === 'shared'; -} - -function shouldSendFallbackSnapshot( +function roomHasLimitedHistoryVisibility( isRoomEncrypted: boolean, - isRoomHistoryVisibilityShared: boolean, roomHistoryVisibilityValue: string | undefined, ): boolean { return ( isRoomEncrypted || - (!isRoomHistoryVisibilityShared && roomHistoryVisibilityValue !== undefined) + (roomHistoryVisibilityValue !== undefined && + roomHistoryVisibilityValue !== 'shared') ); } @@ -129,7 +142,7 @@ function getMembershipFilter( member.content.membership === 'join'; } -function shouldUseInviteTimestamp( +function shouldUseInvite( lastInviteOrJoin: StateEvent, roomHistoryVisibilityValue: string | undefined, ) { diff --git a/packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.test.tsx b/packages/react-sdk/src/state/usePersistOnJoinorInvite.test.tsx similarity index 52% rename from packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.test.tsx rename to packages/react-sdk/src/state/usePersistOnJoinorInvite.test.tsx index 8ed1ad3f..92b88126 100644 --- a/packages/react-sdk/src/components/FallbackSnapshotProvider/FallbackSnapshotProvider.test.tsx +++ b/packages/react-sdk/src/state/usePersistOnJoinorInvite.test.tsx @@ -15,7 +15,7 @@ */ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { ComponentType, PropsWithChildren } from 'react'; import { afterEach, @@ -28,21 +28,21 @@ import { Mocked, vi, } from 'vitest'; +import { App } from '../App'; import { mockWhiteboardManager, WhiteboardTestingContextProvider, -} from '../../lib/testUtils'; +} from '../lib/testUtils'; import { mockRoomEncryption, mockRoomHistoryVisibility, -} from '../../lib/testUtils/matrixTestUtils'; -import * as state from '../../state'; +} from '../lib/testUtils/matrixTestUtils'; +import * as state from '../state'; +import * as storeApi from '../store/api'; import { - useGetRoomEncryptionQuery, - useGetRoomHistoryVisibilityQuery, - useGetRoomMembersQuery, -} from '../../store'; -import { FallbackSnapshotProvider } from './FallbackSnapshotProvider'; + cancelableSnapshotTimer, + usePersistOnJoinOrInvite, +} from './usePersistOnJoinOrInvite'; const mockRoomMembersOtherUser = { entities: { @@ -74,6 +74,14 @@ const mockRoomMembersOwnUser = { }, }; +const mockLatestDocumentSnapshot = { + event: { origin_server_ts: 100 }, +}; + +const mockRecentDocumentSnapshot = { + event: { origin_server_ts: 3000 }, +}; + const mockRoomHistoryVisibilityInvited = mockRoomHistoryVisibility({ content: { history_visibility: 'invited' }, }); @@ -84,27 +92,42 @@ const mockRoomHistoryVisibilityShared = mockRoomHistoryVisibility(); const mockWhiteboardInstance = { persist: vi.fn(), + isLoading: () => false, + getDocumentId: () => '$document-0', }; let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); beforeEach(() => (widgetApi = mockWidgetApi())); -describe('', () => { +describe('usePersistOnJoinOrInvite', () => { let Wrapper: ComponentType>; let whiteboardManager: Mocked; beforeAll(() => { - vi.mock('../../store/api', () => { + vi.mock('./usePersistOnJoinOrInvite', async () => { + const usePersistOnJoinOrInvite = await vi.importActual< + typeof import('./usePersistOnJoinOrInvite') + >('./usePersistOnJoinOrInvite'); return { + ...usePersistOnJoinOrInvite, + cancelableSnapshotTimer: vi.fn(() => ({ cancel: vi.fn() })), + }; + }); + vi.mock('../store/api', async () => { + const storeApi = + await vi.importActual('../store/api'); + return { + ...storeApi, + useGetDocumentSnapshotQuery: vi.fn(), useGetRoomMembersQuery: vi.fn(), useGetRoomEncryptionQuery: vi.fn(), useGetRoomHistoryVisibilityQuery: vi.fn(), }; }); - vi.mock('../../state', async () => { + vi.mock('../state', async () => { const state = - await vi.importActual('../../state'); + await vi.importActual('../state'); return { ...state, useActiveWhiteboardInstance: vi.fn(), @@ -120,44 +143,71 @@ describe('', () => { (state.useActiveWhiteboardInstance as Mock).mockReturnValue( mockWhiteboardInstance, ); - (useGetRoomMembersQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomMembersQuery as Mock).mockReturnValue({ data: mockRoomMembersOtherUser, }); + (storeApi.useGetDocumentSnapshotQuery as Mock).mockReturnValue({ + data: mockLatestDocumentSnapshot, + }); - Wrapper = ({ children }: PropsWithChildren<{}>) => { - return ( - - {children} - - ); - }; + ({ whiteboardManager } = mockWhiteboardManager()); + + Wrapper = () => ( + + + + ); }); it('should not persist snapshot if room is unencrypted and history is shared', () => { - (useGetRoomEncryptionQuery as Mock).mockReturnValue({ data: undefined }); - (useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ + data: undefined, + }); + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ data: { event: mockRoomHistoryVisibilityShared }, }); - render(); + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, + }); + + expect(mockWhiteboardInstance.persist).not.toHaveBeenCalled(); + }); + + it('should not persist snapshot if the snapshot is more recent than the last membership event', () => { + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ + data: { event: mockRoomEncryption() }, + }); + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + data: { event: mockRoomHistoryVisibilityInvited }, + }); + (storeApi.useGetDocumentSnapshotQuery as Mock).mockReturnValue({ + data: mockRecentDocumentSnapshot, + }); + + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, + }); expect(mockWhiteboardInstance.persist).not.toHaveBeenCalled(); }); it('should persist snapshot if the room is encrypted', () => { - (useGetRoomEncryptionQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ data: { event: mockRoomEncryption() }, }); - (useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ data: { event: mockRoomHistoryVisibilityShared }, }); - render(); + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, + }); - expect(mockWhiteboardInstance.persist).toHaveBeenCalled(); + expect(cancelableSnapshotTimer).toHaveBeenCalled(); }); it.each([ @@ -166,19 +216,18 @@ describe('', () => { ])( 'should persist snapshot if the last event is a join and history visibility is invited (%s room)', (_, encryptionState) => { - (useGetRoomEncryptionQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ data: encryptionState, }); - (useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ data: { event: mockRoomHistoryVisibilityInvited }, }); - render(); - - expect(mockWhiteboardInstance.persist).toHaveBeenCalledWith({ - timestamp: 2000, - immediate: false, + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, }); + + expect(cancelableSnapshotTimer).toHaveBeenCalled(); }, ); @@ -188,19 +237,18 @@ describe('', () => { ])( 'should persist snapshot if the last event is a join and history visibility is joined (%s room)', (_, encryptionState) => { - (useGetRoomEncryptionQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ data: encryptionState, }); - (useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ data: { event: mockRoomHistoryVisibilityJoined }, }); - render(); - - expect(mockWhiteboardInstance.persist).toHaveBeenCalledWith({ - timestamp: 1000, - immediate: false, + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, }); + + expect(cancelableSnapshotTimer).toHaveBeenCalled(); }, ); @@ -210,22 +258,21 @@ describe('', () => { ])( 'should persist snapshot immediatly if the last event is an invite and inviter is the current user (%s room)', (_, encryptionState) => { - (useGetRoomMembersQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomMembersQuery as Mock).mockReturnValue({ data: mockRoomMembersOwnUser, }); - (useGetRoomEncryptionQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomEncryptionQuery as Mock).mockReturnValue({ data: encryptionState, }); - (useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ + (storeApi.useGetRoomHistoryVisibilityQuery as Mock).mockReturnValue({ data: { event: mockRoomHistoryVisibilityInvited }, }); - render(); - - expect(mockWhiteboardInstance.persist).toHaveBeenCalledWith({ - timestamp: 2000, - immediate: true, + const { result: _result } = renderHook(usePersistOnJoinOrInvite, { + wrapper: Wrapper, }); + + expect(mockWhiteboardInstance.persist).toHaveBeenCalled(); }, ); }); diff --git a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts index de6897e4..2eb0a869 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts @@ -81,7 +81,6 @@ 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 34019cc6..f622adcc 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.ts @@ -15,7 +15,7 @@ */ import { StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; -import { cloneDeep, debounce, isEqual } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import { BehaviorSubject, Observable, @@ -60,7 +60,6 @@ import { PresentationManagerImpl } from './presentationManagerImpl'; import { LocalForageDocumentStorage } from './storage'; import { SynchronizedDocumentImpl } from './synchronizedDocumentImpl'; import { - PersistOptions, PresentationManager, SynchronizedDocument, WhiteboardInstance, @@ -388,6 +387,10 @@ export class WhiteboardInstanceImpl implements WhiteboardInstance { } } + getDocumentId(): string { + return this.whiteboardEvent.content.documentId; + } + getWhiteboardId(): string { return this.whiteboardEvent.event_id; } @@ -453,23 +456,8 @@ export class WhiteboardInstanceImpl implements WhiteboardInstance { this.communicationChannel.destroy(); } - async persist(options?: PersistOptions): Promise { - if (options !== undefined) { - const snapshot = this.synchronizedDocument.getLatestDocumentSnapshot(); - if (snapshot && snapshot.origin_server_ts < options.timestamp) { - if (options.immediate) { - await this.synchronizedDocument.persist(true); - } else { - const delay = Math.floor(Math.random() * 20) + 10; - debounce( - () => this.synchronizedDocument.persist(true), - delay * 1000, - )(); - } - } - } else { - await this.synchronizedDocument.persist(false); - } + async persist(force: boolean = false): Promise { + await this.synchronizedDocument.persist(force); } clearUndoManager(): void { diff --git a/packages/react-sdk/src/store/api/documentSnapshotApi.ts b/packages/react-sdk/src/store/api/documentSnapshotApi.ts index 92e8c68e..1179ade3 100644 --- a/packages/react-sdk/src/store/api/documentSnapshotApi.ts +++ b/packages/react-sdk/src/store/api/documentSnapshotApi.ts @@ -358,4 +358,5 @@ export function createChunks( } // consume the store using the hooks generated by RTK Query -export const { useCreateDocumentMutation } = documentSnapshotApi; +export const { useCreateDocumentMutation, useGetDocumentSnapshotQuery } = + documentSnapshotApi; diff --git a/packages/react-sdk/src/store/api/index.ts b/packages/react-sdk/src/store/api/index.ts index 2e163928..1602404a 100644 --- a/packages/react-sdk/src/store/api/index.ts +++ b/packages/react-sdk/src/store/api/index.ts @@ -18,6 +18,7 @@ export { baseApi } from './baseApi'; export { documentSnapshotApi, useCreateDocumentMutation, + useGetDocumentSnapshotQuery, } from './documentSnapshotApi'; export { powerLevelsApi,