Skip to content

Commit c2fdb44

Browse files
author
Kerry
authored
Live location sharing - create m.beacon_info events (#2238)
* add content helpers Signed-off-by: Kerry Archibald <kerrya@element.io> * stubbed Beacon class Signed-off-by: Kerry Archibald <kerrya@element.io> * beacon test utils Signed-off-by: Kerry Archibald <kerrya@element.io> * add beacon test utils Signed-off-by: Kerry Archibald <kerrya@element.io> * copyrights Signed-off-by: Kerry Archibald <kerrya@element.io> * add beacons to room state Signed-off-by: Kerry Archibald <kerrya@element.io> * tidy comments Signed-off-by: Kerry Archibald <kerrya@element.io> * unit test RoomState.setBeacon Signed-off-by: Kerry Archibald <kerrya@element.io>
1 parent 57d71cc commit c2fdb44

File tree

8 files changed

+467
-6
lines changed

8 files changed

+467
-6
lines changed

spec/test-utils/beacon.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { MatrixEvent } from "../../src";
18+
import { M_BEACON, M_BEACON_INFO } from "../../src/@types/beacon";
19+
import { LocationAssetType } from "../../src/@types/location";
20+
import {
21+
makeBeaconContent,
22+
makeBeaconInfoContent,
23+
} from "../../src/content-helpers";
24+
25+
type InfoContentProps = {
26+
timeout: number;
27+
isLive?: boolean;
28+
assetType?: LocationAssetType;
29+
description?: string;
30+
};
31+
const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = {
32+
timeout: 3600000,
33+
};
34+
35+
/**
36+
* Create an m.beacon_info event
37+
* all required properties are mocked
38+
* override with contentProps
39+
*/
40+
export const makeBeaconInfoEvent = (
41+
sender: string,
42+
roomId: string,
43+
contentProps: Partial<InfoContentProps> = {},
44+
eventId?: string,
45+
): MatrixEvent => {
46+
const {
47+
timeout, isLive, description, assetType,
48+
} = {
49+
...DEFAULT_INFO_CONTENT_PROPS,
50+
...contentProps,
51+
};
52+
const event = new MatrixEvent({
53+
type: `${M_BEACON_INFO.name}.${sender}`,
54+
room_id: roomId,
55+
state_key: sender,
56+
content: makeBeaconInfoContent(timeout, isLive, description, assetType),
57+
});
58+
59+
// live beacons use the beacon_info event id
60+
// set or default this
61+
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`);
62+
63+
return event;
64+
};
65+
66+
type ContentProps = {
67+
uri: string;
68+
timestamp: number;
69+
beaconInfoId: string;
70+
description?: string;
71+
};
72+
const DEFAULT_CONTENT_PROPS: ContentProps = {
73+
uri: 'geo:-36.24484561954707,175.46884959563613;u=10',
74+
timestamp: 123,
75+
beaconInfoId: '$123',
76+
};
77+
78+
/**
79+
* Create an m.beacon event
80+
* all required properties are mocked
81+
* override with contentProps
82+
*/
83+
export const makeBeaconEvent = (
84+
sender: string,
85+
contentProps: Partial<ContentProps> = {},
86+
): MatrixEvent => {
87+
const { uri, timestamp, beaconInfoId, description } = {
88+
...DEFAULT_CONTENT_PROPS,
89+
...contentProps,
90+
};
91+
92+
return new MatrixEvent({
93+
type: M_BEACON.name,
94+
sender,
95+
content: makeBeaconContent(uri, timestamp, beaconInfoId, description),
96+
});
97+
};
98+
99+
/**
100+
* Create a mock geolocation position
101+
* defaults all required properties
102+
*/
103+
export const makeGeolocationPosition = (
104+
{ timestamp, coords }:
105+
{ timestamp?: number, coords: Partial<GeolocationCoordinates> },
106+
): GeolocationPosition => ({
107+
timestamp: timestamp ?? 1647256791840,
108+
coords: {
109+
accuracy: 1,
110+
latitude: 54.001927,
111+
longitude: -8.253491,
112+
altitude: null,
113+
altitudeAccuracy: null,
114+
heading: null,
115+
speed: null,
116+
...coords,
117+
},
118+
});

spec/unit/models/beacon.spec.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { EventType } from "../../../src";
18+
import { M_BEACON_INFO } from "../../../src/@types/beacon";
19+
import {
20+
isTimestampInDuration,
21+
isBeaconInfoEventType,
22+
Beacon,
23+
BeaconEvent,
24+
} from "../../../src/models/beacon";
25+
import { makeBeaconInfoEvent } from "../../test-utils/beacon";
26+
27+
describe('Beacon', () => {
28+
describe('isTimestampInDuration()', () => {
29+
const startTs = new Date('2022-03-11T12:07:47.592Z').getTime();
30+
const HOUR_MS = 3600000;
31+
it('returns false when timestamp is before start time', () => {
32+
// day before
33+
const timestamp = new Date('2022-03-10T12:07:47.592Z').getTime();
34+
expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false);
35+
});
36+
37+
it('returns false when timestamp is after start time + duration', () => {
38+
// 1 second later
39+
const timestamp = new Date('2022-03-10T12:07:48.592Z').getTime();
40+
expect(isTimestampInDuration(startTs, HOUR_MS, timestamp)).toBe(false);
41+
});
42+
43+
it('returns true when timestamp is exactly start time', () => {
44+
expect(isTimestampInDuration(startTs, HOUR_MS, startTs)).toBe(true);
45+
});
46+
47+
it('returns true when timestamp is exactly the end of the duration', () => {
48+
expect(isTimestampInDuration(startTs, HOUR_MS, startTs + HOUR_MS)).toBe(true);
49+
});
50+
51+
it('returns true when timestamp is within the duration', () => {
52+
const twoHourDuration = HOUR_MS * 2;
53+
const now = startTs + HOUR_MS;
54+
expect(isTimestampInDuration(startTs, twoHourDuration, now)).toBe(true);
55+
});
56+
});
57+
58+
describe('isBeaconInfoEventType', () => {
59+
it.each([
60+
EventType.CallAnswer,
61+
`prefix.${M_BEACON_INFO.name}`,
62+
`prefix.${M_BEACON_INFO.altName}`,
63+
])('returns false for %s', (type) => {
64+
expect(isBeaconInfoEventType(type)).toBe(false);
65+
});
66+
67+
it.each([
68+
M_BEACON_INFO.name,
69+
M_BEACON_INFO.altName,
70+
`${M_BEACON_INFO.name}.@test:server.org.12345`,
71+
`${M_BEACON_INFO.altName}.@test:server.org.12345`,
72+
])('returns true for %s', (type) => {
73+
expect(isBeaconInfoEventType(type)).toBe(true);
74+
});
75+
});
76+
77+
describe('Beacon', () => {
78+
const userId = '@user:server.org';
79+
const roomId = '$room:server.org';
80+
// 14.03.2022 16:15
81+
const now = 1647270879403;
82+
const HOUR_MS = 3600000;
83+
84+
// beacon_info events
85+
// created 'an hour ago'
86+
// without timeout of 3 hours
87+
let liveBeaconEvent;
88+
let notLiveBeaconEvent;
89+
beforeEach(() => {
90+
// go back in time to create the beacon
91+
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS);
92+
liveBeaconEvent = makeBeaconInfoEvent(userId, roomId, { timeout: HOUR_MS * 3, isLive: true }, '$live123');
93+
notLiveBeaconEvent = makeBeaconInfoEvent(
94+
userId,
95+
roomId,
96+
{ timeout: HOUR_MS * 3, isLive: false },
97+
'$dead123',
98+
);
99+
100+
// back to now
101+
jest.spyOn(global.Date, 'now').mockReturnValue(now);
102+
});
103+
104+
afterAll(() => {
105+
jest.spyOn(global.Date, 'now').mockRestore();
106+
});
107+
108+
it('creates beacon from event', () => {
109+
const beacon = new Beacon(liveBeaconEvent);
110+
111+
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
112+
expect(beacon.isLive).toEqual(true);
113+
});
114+
115+
describe('isLive()', () => {
116+
it('returns false when beacon is explicitly set to not live', () => {
117+
const beacon = new Beacon(notLiveBeaconEvent);
118+
expect(beacon.isLive).toEqual(false);
119+
});
120+
121+
it('returns false when beacon is expired', () => {
122+
// time travel to beacon creation + 3 hours
123+
jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS);
124+
const beacon = new Beacon(liveBeaconEvent);
125+
expect(beacon.isLive).toEqual(false);
126+
});
127+
128+
it('returns false when beacon timestamp is in future', () => {
129+
// time travel to before beacon events timestamp
130+
// event was created now - 1 hour
131+
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS);
132+
const beacon = new Beacon(liveBeaconEvent);
133+
expect(beacon.isLive).toEqual(false);
134+
});
135+
136+
it('returns true when beacon was created in past and not yet expired', () => {
137+
// liveBeaconEvent was created 1 hour ago
138+
const beacon = new Beacon(liveBeaconEvent);
139+
expect(beacon.isLive).toEqual(true);
140+
});
141+
});
142+
143+
describe('update()', () => {
144+
it('does not update with different event', () => {
145+
const beacon = new Beacon(liveBeaconEvent);
146+
147+
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
148+
149+
expect(() => beacon.update(notLiveBeaconEvent)).toThrow();
150+
expect(beacon.isLive).toEqual(true);
151+
});
152+
153+
it('updates event', () => {
154+
const beacon = new Beacon(liveBeaconEvent);
155+
const emitSpy = jest.spyOn(beacon, 'emit');
156+
157+
expect(beacon.isLive).toEqual(true);
158+
159+
const updatedBeaconEvent = makeBeaconInfoEvent(
160+
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123');
161+
162+
beacon.update(updatedBeaconEvent);
163+
expect(beacon.isLive).toEqual(false);
164+
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Update, updatedBeaconEvent, beacon);
165+
});
166+
});
167+
});
168+
});

spec/unit/room-state.spec.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as utils from "../test-utils/test-utils";
2+
import { makeBeaconInfoEvent } from "../test-utils/beacon";
23
import { RoomState } from "../../src/models/room-state";
34

45
describe("RoomState", function() {
@@ -248,6 +249,32 @@ describe("RoomState", function() {
248249
memberEvent, state,
249250
);
250251
});
252+
253+
it('adds new beacon info events to state', () => {
254+
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
255+
256+
state.setStateEvents([beaconEvent]);
257+
258+
expect(state.beacons.size).toEqual(1);
259+
expect(state.beacons.get(beaconEvent.getId())).toBeTruthy();
260+
});
261+
262+
it('updates existing beacon info events in state', () => {
263+
const beaconId = '$beacon1';
264+
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
265+
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId);
266+
267+
state.setStateEvents([beaconEvent]);
268+
const beaconInstance = state.beacons.get(beaconId);
269+
expect(beaconInstance.isLive).toEqual(true);
270+
271+
state.setStateEvents([updatedBeaconEvent]);
272+
273+
// same Beacon
274+
expect(state.beacons.get(beaconId)).toBe(beaconInstance);
275+
// updated liveness
276+
expect(state.beacons.get(beaconId).isLive).toEqual(false);
277+
});
251278
});
252279

253280
describe("setOutOfBandMembers", function() {

src/@types/beacon.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ export type MBeaconInfoEventContent = &
135135
}
136136
*/
137137

138+
/**
139+
* Content of an m.beacon event
140+
*/
138141
export type MBeaconEventContent = &
139142
MLocationEvent &
140143
// timestamp when location was taken

src/client.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import { MediaHandler } from "./webrtc/mediaHandler";
179179
import { IRefreshTokenResponse } from "./@types/auth";
180180
import { TypedEventEmitter } from "./models/typed-event-emitter";
181181
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
182+
import { MBeaconInfoEventContent, M_BEACON_INFO_VARIABLE } from "./@types/beacon";
182183

183184
export type Store = IStore;
184185
export type SessionStore = WebStorageSessionStore;
@@ -3670,6 +3671,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
36703671
return this.http.authedRequest(callback, Method.Put, path, undefined, content);
36713672
}
36723673

3674+
/**
3675+
* Create an m.beacon_info event
3676+
* @param {string} roomId
3677+
* @param {MBeaconInfoEventContent} beaconInfoContent
3678+
* @param {string} eventTypeSuffix - string to suffix event type
3679+
* to make event type unique.
3680+
* See MSC3489 for more context
3681+
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
3682+
* @returns {ISendEventResponse}
3683+
*/
3684+
// eslint-disable-next-line @typescript-eslint/naming-convention
3685+
unstable_createLiveBeacon(
3686+
roomId: Room["roomId"],
3687+
beaconInfoContent: MBeaconInfoEventContent,
3688+
eventTypeSuffix: string,
3689+
) {
3690+
const userId = this.getUserId();
3691+
const eventType = M_BEACON_INFO_VARIABLE.name.replace('*', `${userId}.${eventTypeSuffix}`);
3692+
return this.sendStateEvent(roomId, eventType, beaconInfoContent, userId);
3693+
}
3694+
36733695
/**
36743696
* @param {string} roomId
36753697
* @param {string} threadId

0 commit comments

Comments
 (0)