diff --git a/spec/test-utils/beacon.ts b/spec/test-utils/beacon.ts new file mode 100644 index 00000000000..39fdeb336ff --- /dev/null +++ b/spec/test-utils/beacon.ts @@ -0,0 +1,118 @@ +/* +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 { MatrixEvent } from "../../src"; +import { M_BEACON, M_BEACON_INFO } from "../../src/@types/beacon"; +import { LocationAssetType } from "../../src/@types/location"; +import { + makeBeaconContent, + makeBeaconInfoContent, +} from "../../src/content-helpers"; + +type InfoContentProps = { + timeout: number; + isLive?: boolean; + assetType?: LocationAssetType; + description?: string; +}; +const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = { + timeout: 3600000, +}; + +/** + * Create an m.beacon_info event + * all required properties are mocked + * override with contentProps + */ +export const makeBeaconInfoEvent = ( + sender: string, + roomId: string, + contentProps: Partial = {}, + eventId?: string, +): MatrixEvent => { + const { + timeout, isLive, description, assetType, + } = { + ...DEFAULT_INFO_CONTENT_PROPS, + ...contentProps, + }; + const event = new MatrixEvent({ + type: `${M_BEACON_INFO.name}.${sender}`, + room_id: roomId, + state_key: sender, + content: makeBeaconInfoContent(timeout, isLive, description, assetType), + }); + + // live beacons use the beacon_info event id + // set or default this + event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`); + + return event; +}; + +type ContentProps = { + uri: string; + timestamp: number; + beaconInfoId: string; + description?: string; +}; +const DEFAULT_CONTENT_PROPS: ContentProps = { + uri: 'geo:-36.24484561954707,175.46884959563613;u=10', + timestamp: 123, + beaconInfoId: '$123', +}; + +/** + * Create an m.beacon event + * all required properties are mocked + * override with contentProps + */ +export const makeBeaconEvent = ( + sender: string, + contentProps: Partial = {}, +): MatrixEvent => { + const { uri, timestamp, beaconInfoId, description } = { + ...DEFAULT_CONTENT_PROPS, + ...contentProps, + }; + + return new MatrixEvent({ + type: M_BEACON.name, + sender, + content: makeBeaconContent(uri, timestamp, beaconInfoId, description), + }); +}; + +/** + * Create a mock geolocation position + * defaults all required properties + */ +export const makeGeolocationPosition = ( + { timestamp, coords }: + { timestamp?: number, coords: Partial }, +): GeolocationPosition => ({ + timestamp: timestamp ?? 1647256791840, + coords: { + accuracy: 1, + latitude: 54.001927, + longitude: -8.253491, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + ...coords, + }, +}); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts new file mode 100644 index 00000000000..61528735d5a --- /dev/null +++ b/spec/unit/models/beacon.spec.ts @@ -0,0 +1,168 @@ +/* +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 { EventType } from "../../../src"; +import { M_BEACON_INFO } from "../../../src/@types/beacon"; +import { + isTimestampInDuration, + isBeaconInfoEventType, + Beacon, + BeaconEvent, +} from "../../../src/models/beacon"; +import { makeBeaconInfoEvent } from "../../test-utils/beacon"; + +describe('Beacon', () => { + describe('isTimestampInDuration()', () => { + const startTs = new Date('2022-03-11T12:07:47.592Z').getTime(); + const HOUR_MS = 3600000; + it('returns false when timestamp is before start time', () => { + // day before + const timestamp = new Date('2022-03-10T12:07:47.592Z').getTime(); + expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false); + }); + + it('returns false when timestamp is after start time + duration', () => { + // 1 second later + const timestamp = new Date('2022-03-10T12:07:48.592Z').getTime(); + expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false); + }); + + it('returns true when timestamp is exactly start time', () => { + expect(isTimestampInDuration(startTs, HOUR_MS, startTs)).toBe(true); + }); + + it('returns true when timestamp is exactly the end of the duration', () => { + expect(isTimestampInDuration(startTs, HOUR_MS, startTs + HOUR_MS)).toBe(true); + }); + + it('returns true when timestamp is within the duration', () => { + const twoHourDuration = HOUR_MS * 2; + const now = startTs + HOUR_MS; + expect(isTimestampInDuration(startTs, twoHourDuration, now)).toBe(true); + }); + }); + + describe('isBeaconInfoEventType', () => { + it.each([ + EventType.CallAnswer, + `prefix.${M_BEACON_INFO.name}`, + `prefix.${M_BEACON_INFO.altName}`, + ])('returns false for %s', (type) => { + expect(isBeaconInfoEventType(type)).toBe(false); + }); + + it.each([ + M_BEACON_INFO.name, + M_BEACON_INFO.altName, + `${M_BEACON_INFO.name}.@test:server.org.12345`, + `${M_BEACON_INFO.altName}.@test:server.org.12345`, + ])('returns true for %s', (type) => { + expect(isBeaconInfoEventType(type)).toBe(true); + }); + }); + + describe('Beacon', () => { + const userId = '@user:server.org'; + const roomId = '$room:server.org'; + // 14.03.2022 16:15 + const now = 1647270879403; + const HOUR_MS = 3600000; + + // beacon_info events + // created 'an hour ago' + // without timeout of 3 hours + let liveBeaconEvent; + let notLiveBeaconEvent; + beforeEach(() => { + // go back in time to create the beacon + jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS); + liveBeaconEvent = makeBeaconInfoEvent(userId, roomId, { timeout: HOUR_MS * 3, isLive: true }, '$live123'); + notLiveBeaconEvent = makeBeaconInfoEvent( + userId, + roomId, + { timeout: HOUR_MS * 3, isLive: false }, + '$dead123', + ); + + // back to now + jest.spyOn(global.Date, 'now').mockReturnValue(now); + }); + + afterAll(() => { + jest.spyOn(global.Date, 'now').mockRestore(); + }); + + it('creates beacon from event', () => { + const beacon = new Beacon(liveBeaconEvent); + + expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); + expect(beacon.isLive).toEqual(true); + }); + + describe('isLive()', () => { + it('returns false when beacon is explicitly set to not live', () => { + const beacon = new Beacon(notLiveBeaconEvent); + expect(beacon.isLive).toEqual(false); + }); + + it('returns false when beacon is expired', () => { + // time travel to beacon creation + 3 hours + jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS); + const beacon = new Beacon(liveBeaconEvent); + expect(beacon.isLive).toEqual(false); + }); + + it('returns false when beacon timestamp is in future', () => { + // time travel to before beacon events timestamp + // event was created now - 1 hour + jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS); + const beacon = new Beacon(liveBeaconEvent); + expect(beacon.isLive).toEqual(false); + }); + + it('returns true when beacon was created in past and not yet expired', () => { + // liveBeaconEvent was created 1 hour ago + const beacon = new Beacon(liveBeaconEvent); + expect(beacon.isLive).toEqual(true); + }); + }); + + describe('update()', () => { + it('does not update with different event', () => { + const beacon = new Beacon(liveBeaconEvent); + + expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); + + expect(() => beacon.update(notLiveBeaconEvent)).toThrow(); + expect(beacon.isLive).toEqual(true); + }); + + it('updates event', () => { + const beacon = new Beacon(liveBeaconEvent); + const emitSpy = jest.spyOn(beacon, 'emit'); + + expect(beacon.isLive).toEqual(true); + + const updatedBeaconEvent = makeBeaconInfoEvent( + userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123'); + + beacon.update(updatedBeaconEvent); + expect(beacon.isLive).toEqual(false); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Update, updatedBeaconEvent, beacon); + }); + }); + }); +}); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 109aecae423..53c38425293 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,4 +1,5 @@ import * as utils from "../test-utils/test-utils"; +import { makeBeaconInfoEvent } from "../test-utils/beacon"; import { RoomState } from "../../src/models/room-state"; describe("RoomState", function() { @@ -248,6 +249,32 @@ describe("RoomState", function() { memberEvent, state, ); }); + + it('adds new beacon info events to state', () => { + const beaconEvent = makeBeaconInfoEvent(userA, roomId); + + state.setStateEvents([beaconEvent]); + + expect(state.beacons.size).toEqual(1); + expect(state.beacons.get(beaconEvent.getId())).toBeTruthy(); + }); + + it('updates existing beacon info events in state', () => { + const beaconId = '$beacon1'; + const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); + const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId); + + state.setStateEvents([beaconEvent]); + const beaconInstance = state.beacons.get(beaconId); + expect(beaconInstance.isLive).toEqual(true); + + state.setStateEvents([updatedBeaconEvent]); + + // same Beacon + expect(state.beacons.get(beaconId)).toBe(beaconInstance); + // updated liveness + expect(state.beacons.get(beaconId).isLive).toEqual(false); + }); }); describe("setOutOfBandMembers", function() { diff --git a/src/@types/beacon.ts b/src/@types/beacon.ts index ff3cf64d264..adf033daa24 100644 --- a/src/@types/beacon.ts +++ b/src/@types/beacon.ts @@ -135,6 +135,9 @@ export type MBeaconInfoEventContent = & } */ +/** + * Content of an m.beacon event + */ export type MBeaconEventContent = & MLocationEvent & // timestamp when location was taken diff --git a/src/client.ts b/src/client.ts index 00b750a4e12..ad3abdab85e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -179,6 +179,7 @@ import { MediaHandler } from "./webrtc/mediaHandler"; import { IRefreshTokenResponse } from "./@types/auth"; import { TypedEventEmitter } from "./models/typed-event-emitter"; import { Thread, THREAD_RELATION_TYPE } from "./models/thread"; +import { MBeaconInfoEventContent, M_BEACON_INFO_VARIABLE } from "./@types/beacon"; export type Store = IStore; export type SessionStore = WebStorageSessionStore; @@ -3670,6 +3671,27 @@ export class MatrixClient extends TypedEventEmitter ({ +) => MBeaconInfoEventContent; + +export const makeBeaconInfoContent: MakeBeaconInfoContent = ( + timeout, + isLive, + description, + assetType, +) => ({ [M_BEACON_INFO.name]: { description, timeout, @@ -211,12 +217,40 @@ export const makeBeaconInfoContent = ( }, }); -export const makeBeaconContent = ( +export type BeaconInfoState = MBeaconInfoContent & { + assetType: LocationAssetType; + timestamp: number; +}; +/** + * Flatten beacon info event content + */ +export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => { + const { description, timeout, live } = M_BEACON_INFO.findIn(content); + const { type: assetType } = M_ASSET.findIn(content); + const timestamp = M_TIMESTAMP.findIn(content); + + return { + description, + timeout, + live, + assetType, + timestamp, + }; +}; + +export type MakeBeaconContent = ( uri: string, timestamp: number, beaconInfoId: string, description?: string, -): MBeaconEventContent => ({ +) => MBeaconEventContent; + +export const makeBeaconContent: MakeBeaconContent = ( + uri, + timestamp, + beaconInfoId, + description, +) => ({ [M_LOCATION.name]: { description, uri, diff --git a/src/models/beacon.ts b/src/models/beacon.ts new file mode 100644 index 00000000000..48112a5ef50 --- /dev/null +++ b/src/models/beacon.ts @@ -0,0 +1,73 @@ +/* +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 { M_BEACON_INFO } from "../@types/beacon"; +import { BeaconInfoState, parseBeaconInfoContent } from "../content-helpers"; +import { MatrixEvent } from "../matrix"; +import { TypedEventEmitter } from "./typed-event-emitter"; + +export enum BeaconEvent { + New = "Beacon.new", + Update = "Beacon.update", +} + +type EmittedEvents = BeaconEvent.New | BeaconEvent.Update; +type EventHandlerMap = { + [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; + [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void; +}; + +export const isTimestampInDuration = ( + startTimestamp: number, + durationMs: number, + timestamp: number, +): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; + +export const isBeaconInfoEventType = (type: string) => + type.startsWith(M_BEACON_INFO.name) || + type.startsWith(M_BEACON_INFO.altName); + +// https://github.com/matrix-org/matrix-spec-proposals/pull/3489 +export class Beacon extends TypedEventEmitter { + private beaconInfo: BeaconInfoState; + + constructor( + private rootEvent: MatrixEvent, + ) { + super(); + this.beaconInfo = parseBeaconInfoContent(this.rootEvent.getContent()); + this.emit(BeaconEvent.New, this.rootEvent, this); + } + + public get isLive(): boolean { + return this.beaconInfo?.live && + isTimestampInDuration(this.beaconInfo?.timestamp, this.beaconInfo?.timeout, Date.now()); + } + + public get beaconInfoId(): string { + return this.rootEvent.getId(); + } + + public update(beaconInfoEvent: MatrixEvent): void { + if (beaconInfoEvent.getId() !== this.beaconInfoId) { + throw new Error('Invalid updating event'); + } + this.rootEvent = beaconInfoEvent; + this.beaconInfo = parseBeaconInfoContent(this.rootEvent.getContent()); + + this.emit(BeaconEvent.Update, beaconInfoEvent, this); + } +} diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 93c76df7289..cd3539602fd 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -26,6 +26,7 @@ import { MatrixEvent } from "./event"; import { MatrixClient } from "../client"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; import { TypedEventEmitter } from "./typed-event-emitter"; +import { Beacon, isBeaconInfoEventType } from "./beacon"; // possible statuses for out-of-band member loading enum OobStatus { @@ -71,6 +72,8 @@ export class RoomState extends TypedEventEmitter>(); // Map> public paginationToken: string = null; + public readonly beacons = new Map(); + /** * Construct room state. * @@ -314,6 +317,10 @@ export class RoomState extends TypedEventEmitter