diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts new file mode 100644 index 0000000000..e42bdc9b04 --- /dev/null +++ b/src/stores/OwnBeaconStore.ts @@ -0,0 +1,152 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 { + Beacon, + BeaconEvent, + MatrixEvent, + Room, +} from "matrix-js-sdk/src/matrix"; + +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; + +const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId; + +export enum OwnBeaconStoreEvent { + LivenessChange = 'OwnBeaconStore.LivenessChange' +} + +type OwnBeaconStoreState = { + beacons: Map; + beaconsByRoomId: Map>; + liveBeaconIds: string[]; +}; +export class OwnBeaconStore extends AsyncStoreWithClient { + private static internalInstance = new OwnBeaconStore(); + public readonly beacons = new Map(); + public readonly beaconsByRoomId = new Map>(); + private liveBeaconIds = []; + + public constructor() { + super(defaultDispatcher); + } + + public static get instance(): OwnBeaconStore { + return OwnBeaconStore.internalInstance; + } + + protected async onNotReady() { + this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); + this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); + + this.beacons.forEach(beacon => beacon.destroy()); + + this.beacons.clear(); + this.beaconsByRoomId.clear(); + this.liveBeaconIds = []; + } + + protected async onReady(): Promise { + this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness); + this.matrixClient.on(BeaconEvent.New, this.onNewBeacon); + + this.initialiseBeaconState(); + } + + protected async onAction(payload: ActionPayload): Promise { + // we don't actually do anything here + } + + public hasLiveBeacons(roomId?: string): boolean { + return !!this.getLiveBeaconIds(roomId).length; + } + + public getLiveBeaconIds(roomId?: string): string[] { + if (!roomId) { + return this.liveBeaconIds; + } + return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId)); + } + + private onNewBeacon = (_event: MatrixEvent, beacon: Beacon): void => { + if (!isOwnBeacon(beacon, this.matrixClient.getUserId())) { + return; + } + this.addBeacon(beacon); + this.checkLiveness(); + }; + + private onBeaconLiveness = (isLive: boolean, beacon: Beacon): void => { + // check if we care about this beacon + if (!this.beacons.has(beacon.beaconInfoId)) { + return; + } + + if (!isLive && this.liveBeaconIds.includes(beacon.beaconInfoId)) { + this.liveBeaconIds = + this.liveBeaconIds.filter(beaconId => beaconId !== beacon.beaconInfoId); + } + + if (isLive && !this.liveBeaconIds.includes(beacon.beaconInfoId)) { + this.liveBeaconIds.push(beacon.beaconInfoId); + } + + this.emit(OwnBeaconStoreEvent.LivenessChange, this.hasLiveBeacons()); + // TODO stop or start polling here + // if not content is live but beacon is not, update state event with live: false + }; + + private initialiseBeaconState = () => { + const userId = this.matrixClient.getUserId(); + const visibleRooms = this.matrixClient.getVisibleRooms(); + + visibleRooms + .forEach(room => { + const roomState = room.currentState; + const beacons = roomState.beacons; + const ownBeaconsArray = [...beacons.values()].filter(beacon => isOwnBeacon(beacon, userId)); + ownBeaconsArray.forEach(beacon => this.addBeacon(beacon)); + }); + + this.checkLiveness(); + }; + + private addBeacon = (beacon: Beacon): void => { + this.beacons.set(beacon.beaconInfoId, beacon); + + if (!this.beaconsByRoomId.has(beacon.roomId)) { + this.beaconsByRoomId.set(beacon.roomId, new Set()); + } + + this.beaconsByRoomId.get(beacon.roomId).add(beacon.beaconInfoId); + beacon.monitorLiveness(); + }; + + private checkLiveness = (): void => { + const prevLiveness = this.hasLiveBeacons(); + this.liveBeaconIds = [...this.beacons.values()] + .filter(beacon => beacon.isLive) + .map(beacon => beacon.beaconInfoId); + + const newLiveness = this.hasLiveBeacons(); + + if (prevLiveness !== newLiveness) { + this.emit(OwnBeaconStoreEvent.LivenessChange, newLiveness); + } + }; +} diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts new file mode 100644 index 0000000000..c334436ee3 --- /dev/null +++ b/test/stores/OwnBeaconStore-test.ts @@ -0,0 +1,389 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 { Room, Beacon, BeaconEvent } from "matrix-js-sdk/src/matrix"; + +import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore"; +import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../test-utils"; +import { makeBeaconInfoEvent } from "../test-utils/beacon"; +import { getMockClientWithEventEmitter } from "../test-utils/client"; + +jest.useFakeTimers(); + +describe('OwnBeaconStore', () => { + // 14.03.2022 16:15 + const now = 1647270879403; + const HOUR_MS = 3600000; + + const aliceId = '@alice:server.org'; + const bobId = '@bob:server.org'; + const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + getVisibleRooms: jest.fn().mockReturnValue([]), + }); + const room1Id = '$room1:server.org'; + const room2Id = '$room2:server.org'; + + // beacon_info events + // created 'an hour ago' + // with timeout of 3 hours + + // event creation sets timestamp to Date.now() + jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS); + const alicesRoom1BeaconInfo = makeBeaconInfoEvent(aliceId, room1Id, { isLive: true }, '$alice-room1-1'); + const alicesRoom2BeaconInfo = makeBeaconInfoEvent(aliceId, room2Id, { isLive: true }, '$alice-room2-1'); + const alicesOldRoomIdBeaconInfo = makeBeaconInfoEvent(aliceId, room1Id, { isLive: false }, '$alice-room1-2'); + const bobsRoom1BeaconInfo = makeBeaconInfoEvent(bobId, room1Id, { isLive: true }, '$bob-room1-1'); + const bobsOldRoom1BeaconInfo = makeBeaconInfoEvent(bobId, room1Id, { isLive: false }, '$bob-room1-2'); + + // make fresh rooms every time + // as we update room state + const makeRoomsWithStateEvents = (stateEvents = []): [Room, Room] => { + const room1 = new Room(room1Id, mockClient, aliceId); + const room2 = new Room(room2Id, mockClient, aliceId); + + room1.currentState.setStateEvents(stateEvents); + room2.currentState.setStateEvents(stateEvents); + mockClient.getVisibleRooms.mockReturnValue([room1, room2]); + + return [room1, room2]; + }; + + const advanceDateAndTime = (ms: number) => { + // bc liveness check uses Date.now we have to advance this mock + jest.spyOn(global.Date, 'now').mockReturnValue(now + ms); + // then advance time for the interval by the same amount + jest.advanceTimersByTime(ms); + }; + + const makeOwnBeaconStore = async () => { + const store = OwnBeaconStore.instance; + + await setupAsyncStoreWithClient(store, mockClient); + return store; + }; + + beforeEach(() => { + mockClient.getVisibleRooms.mockReturnValue([]); + jest.spyOn(global.Date, 'now').mockReturnValue(now); + jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); + }); + + afterEach(async () => { + await resetAsyncStoreWithClient(OwnBeaconStore.instance); + }); + + it('works', async () => { + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(false); + }); + + describe('onReady()', () => { + it('initialises correctly with no beacons', async () => { + makeRoomsWithStateEvents(); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(false); + expect(store.getLiveBeaconIds()).toEqual([]); + }); + + it('does not add other users beacons to beacon state', async () => { + makeRoomsWithStateEvents([bobsRoom1BeaconInfo, bobsOldRoom1BeaconInfo]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(false); + expect(store.getLiveBeaconIds()).toEqual([]); + }); + + it('adds own users beacons to state', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(true); + expect(store.getLiveBeaconIds()).toEqual([ + alicesRoom1BeaconInfo.getId(), + alicesRoom2BeaconInfo.getId(), + ]); + }); + }); + + describe('onNotReady()', () => { + it('removes listeners', async () => { + const store = await makeOwnBeaconStore(); + const removeSpy = jest.spyOn(mockClient, 'removeListener'); + // @ts-ignore + store.onNotReady(); + + expect(removeSpy.mock.calls[0]).toEqual(expect.arrayContaining([BeaconEvent.LivenessChange])); + expect(removeSpy.mock.calls[1]).toEqual(expect.arrayContaining([BeaconEvent.New])); + }); + + it('destroys beacons', async () => { + const [room1] = makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const beacon = room1.currentState.beacons.get(alicesRoom1BeaconInfo.getId()); + const destroySpy = jest.spyOn(beacon, 'destroy'); + // @ts-ignore + store.onNotReady(); + + expect(destroySpy).toHaveBeenCalled(); + }); + }); + + describe('hasLiveBeacons()', () => { + beforeEach(() => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + }); + + it('returns true when user has live beacons', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(true); + }); + + it('returns false when user does not have live beacons', async () => { + makeRoomsWithStateEvents([ + alicesOldRoomIdBeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(false); + }); + + it('returns true when user has live beacons for roomId', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons(room1Id)).toBe(true); + }); + + it('returns false when user does not have live beacons for roomId', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons(room2Id)).toBe(false); + }); + }); + + describe('getLiveBeaconIds()', () => { + beforeEach(() => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + }); + + it('returns live beacons when user has live beacons', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.getLiveBeaconIds()).toEqual([ + alicesRoom1BeaconInfo.getId(), + ]); + }); + + it('returns empty array when user does not have live beacons', async () => { + makeRoomsWithStateEvents([ + alicesOldRoomIdBeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.getLiveBeaconIds()).toEqual([]); + }); + + it('returns beacon ids for room when user has live beacons for roomId', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.getLiveBeaconIds(room1Id)).toEqual([ + alicesRoom1BeaconInfo.getId(), + ]); + expect(store.getLiveBeaconIds(room2Id)).toEqual([ + alicesRoom2BeaconInfo.getId(), + ]); + }); + + it('returns empty array when user does not have live beacons for roomId', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesOldRoomIdBeaconInfo, + bobsRoom1BeaconInfo, + bobsOldRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + expect(store.getLiveBeaconIds(room2Id)).toEqual([]); + }); + }); + + describe('on new beacon event', () => { + it('ignores events for irrelevant beacons', async () => { + makeRoomsWithStateEvents([]); + const store = await makeOwnBeaconStore(); + const bobsLiveBeacon = new Beacon(bobsRoom1BeaconInfo); + const monitorSpy = jest.spyOn(bobsLiveBeacon, 'monitorLiveness'); + + mockClient.emit(BeaconEvent.New, bobsRoom1BeaconInfo, bobsLiveBeacon); + + // we dont care about bob + expect(monitorSpy).not.toHaveBeenCalled(); + expect(store.hasLiveBeacons()).toBe(false); + }); + + it('adds users beacons to state and monitors liveness', async () => { + makeRoomsWithStateEvents([]); + const store = await makeOwnBeaconStore(); + const alicesLiveBeacon = new Beacon(alicesRoom1BeaconInfo); + const monitorSpy = jest.spyOn(alicesLiveBeacon, 'monitorLiveness'); + + mockClient.emit(BeaconEvent.New, alicesRoom1BeaconInfo, alicesLiveBeacon); + + expect(monitorSpy).toHaveBeenCalled(); + expect(store.hasLiveBeacons()).toBe(true); + expect(store.hasLiveBeacons(room1Id)).toBe(true); + }); + + it('emits a liveness change event when new beacons change live state', async () => { + makeRoomsWithStateEvents([]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const alicesLiveBeacon = new Beacon(alicesRoom1BeaconInfo); + + mockClient.emit(BeaconEvent.New, alicesRoom1BeaconInfo, alicesLiveBeacon); + + expect(emitSpy).toHaveBeenCalledWith(OwnBeaconStoreEvent.LivenessChange, true); + }); + + it('does not emit a liveness change event when new beacons do not change live state', async () => { + makeRoomsWithStateEvents([ + alicesRoom2BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // already live + expect(store.hasLiveBeacons()).toBe(true); + const emitSpy = jest.spyOn(store, 'emit'); + const alicesLiveBeacon = new Beacon(alicesRoom1BeaconInfo); + + mockClient.emit(BeaconEvent.New, alicesRoom1BeaconInfo, alicesLiveBeacon); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('on liveness change event', () => { + it('ignores events for irrelevant beacons', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + const emitSpy = jest.spyOn(store, 'emit'); + const oldLiveBeaconIds = store.getLiveBeaconIds(); + const bobsLiveBeacon = new Beacon(bobsRoom1BeaconInfo); + + mockClient.emit(BeaconEvent.LivenessChange, true, bobsLiveBeacon); + + expect(emitSpy).not.toHaveBeenCalled(); + // strictly equal + expect(store.getLiveBeaconIds()).toBe(oldLiveBeaconIds); + }); + + it('updates state and when beacon liveness changes from true to false', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + + // live before + expect(store.hasLiveBeacons()).toBe(true); + const emitSpy = jest.spyOn(store, 'emit'); + const alicesBeacon = new Beacon(alicesRoom1BeaconInfo); + + // time travel until beacon is expired + advanceDateAndTime(HOUR_MS * 3); + + mockClient.emit(BeaconEvent.LivenessChange, false, alicesBeacon); + + expect(store.hasLiveBeacons()).toBe(false); + expect(store.hasLiveBeacons(room1Id)).toBe(false); + expect(emitSpy).toHaveBeenCalledWith(OwnBeaconStoreEvent.LivenessChange, false); + }); + + it('updates state and when beacon liveness changes from false to true', async () => { + makeRoomsWithStateEvents([ + alicesOldRoomIdBeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + + // not live before + expect(store.hasLiveBeacons()).toBe(false); + const emitSpy = jest.spyOn(store, 'emit'); + const alicesBeacon = new Beacon(alicesOldRoomIdBeaconInfo); + const liveUpdate = makeBeaconInfoEvent( + aliceId, room1Id, { isLive: true }, alicesOldRoomIdBeaconInfo.getId(), + ); + + // bring the beacon back to life + alicesBeacon.update(liveUpdate); + + mockClient.emit(BeaconEvent.LivenessChange, true, alicesBeacon); + + expect(store.hasLiveBeacons()).toBe(true); + expect(store.hasLiveBeacons(room1Id)).toBe(true); + expect(emitSpy).toHaveBeenCalledWith(OwnBeaconStoreEvent.LivenessChange, true); + }); + }); + + describe('on LivenessChange event', () => { + it('ignores events for irrelevant beacons', async () => { + + }); + }); +}); diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 68fc4c7414..7f6b69f1d7 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,3 +1,5 @@ +export * from './beacon'; +export * from './client'; export * from './test-utils'; export * from './wrappers'; export * from './utilities';