Skip to content

Commit f963fea

Browse files
author
Kerry
authored
Live location sharing - Aggregate beacon locations on beacons (#2268)
* add timestamp sorting util Signed-off-by: Kerry Archibald <[email protected]> * basic wiring Signed-off-by: Kerry Archibald <[email protected]> * quick handle for redacted beacons Signed-off-by: Kerry Archibald <[email protected]> * remove fdescribe Signed-off-by: Kerry Archibald <[email protected]> * test adding locations Signed-off-by: Kerry Archibald <[email protected]> * tidy comments Signed-off-by: Kerry Archibald <[email protected]> * test client Signed-off-by: Kerry Archibald <[email protected]> * fix monitorLiveness for update Signed-off-by: Kerry Archibald <[email protected]> * lint Signed-off-by: Kerry Archibald <[email protected]>
1 parent 6d0f4e5 commit f963fea

File tree

10 files changed

+334
-7
lines changed

10 files changed

+334
-7
lines changed

spec/unit/matrix-client.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import * as testUtils from "../test-utils/test-utils";
3333
import { makeBeaconInfoContent } from "../../src/content-helpers";
3434
import { M_BEACON_INFO } from "../../src/@types/beacon";
3535
import { Room } from "../../src";
36+
import { makeBeaconEvent } from "../test-utils/beacon";
3637

3738
jest.useFakeTimers();
3839

@@ -1025,5 +1026,34 @@ describe("MatrixClient", function() {
10251026
);
10261027
expect(requestContent).toEqual(content);
10271028
});
1029+
1030+
describe('processBeaconEvents()', () => {
1031+
it('does nothing when events is falsy', () => {
1032+
const room = new Room(roomId, client, userId);
1033+
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
1034+
1035+
client.processBeaconEvents(room, undefined);
1036+
expect(roomStateProcessSpy).not.toHaveBeenCalled();
1037+
});
1038+
1039+
it('does nothing when events is of length 0', () => {
1040+
const room = new Room(roomId, client, userId);
1041+
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
1042+
1043+
client.processBeaconEvents(room, []);
1044+
expect(roomStateProcessSpy).not.toHaveBeenCalled();
1045+
});
1046+
1047+
it('calls room states processBeaconEvents with m.beacon events', () => {
1048+
const room = new Room(roomId, client, userId);
1049+
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
1050+
1051+
const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true });
1052+
const beaconEvent = makeBeaconEvent(userId);
1053+
1054+
client.processBeaconEvents(room, [messageEvent, beaconEvent]);
1055+
expect(roomStateProcessSpy).toHaveBeenCalledWith([beaconEvent]);
1056+
});
1057+
});
10281058
});
10291059
});

spec/unit/models/beacon.spec.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
Beacon,
2020
BeaconEvent,
2121
} from "../../../src/models/beacon";
22-
import { makeBeaconInfoEvent } from "../../test-utils/beacon";
22+
import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon";
2323

2424
jest.useFakeTimers();
2525

@@ -282,5 +282,93 @@ describe('Beacon', () => {
282282
expect(emitSpy).toHaveBeenCalledTimes(1);
283283
});
284284
});
285+
286+
describe('addLocations', () => {
287+
it('ignores locations when beacon is not live', () => {
288+
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false }));
289+
const emitSpy = jest.spyOn(beacon, 'emit');
290+
291+
beacon.addLocations([
292+
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }),
293+
]);
294+
295+
expect(beacon.latestLocationState).toBeFalsy();
296+
expect(emitSpy).not.toHaveBeenCalled();
297+
});
298+
299+
it('ignores locations outside the beacon live duration', () => {
300+
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
301+
const emitSpy = jest.spyOn(beacon, 'emit');
302+
303+
beacon.addLocations([
304+
// beacon has now + 60000 live period
305+
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 100000 }),
306+
]);
307+
308+
expect(beacon.latestLocationState).toBeFalsy();
309+
expect(emitSpy).not.toHaveBeenCalled();
310+
});
311+
312+
it('sets latest location state to most recent location', () => {
313+
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
314+
const emitSpy = jest.spyOn(beacon, 'emit');
315+
316+
const locations = [
317+
// older
318+
makeBeaconEvent(
319+
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
320+
),
321+
// newer
322+
makeBeaconEvent(
323+
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
324+
),
325+
// not valid
326+
makeBeaconEvent(
327+
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 },
328+
),
329+
];
330+
331+
beacon.addLocations(locations);
332+
333+
const expectedLatestLocation = {
334+
description: undefined,
335+
timestamp: now + 10000,
336+
uri: 'geo:bar',
337+
};
338+
339+
// the newest valid location
340+
expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
341+
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
342+
});
343+
344+
it('ignores locations that are less recent that the current latest location', () => {
345+
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
346+
347+
const olderLocation = makeBeaconEvent(
348+
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
349+
);
350+
const newerLocation = makeBeaconEvent(
351+
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
352+
);
353+
354+
beacon.addLocations([newerLocation]);
355+
// latest location set to newerLocation
356+
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
357+
uri: 'geo:bar',
358+
}));
359+
360+
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
361+
362+
// add older location
363+
beacon.addLocations([olderLocation]);
364+
365+
// no change
366+
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
367+
uri: 'geo:bar',
368+
}));
369+
// no emit
370+
expect(emitSpy).not.toHaveBeenCalled();
371+
});
372+
});
285373
});
286374
});

spec/unit/room-state.spec.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as utils from "../test-utils/test-utils";
2-
import { makeBeaconInfoEvent } from "../test-utils/beacon";
2+
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
33
import { filterEmitCallsByEventType } from "../test-utils/emitter";
44
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
55
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
@@ -712,4 +712,57 @@ describe("RoomState", function() {
712712
expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false);
713713
});
714714
});
715+
716+
describe('processBeaconEvents', () => {
717+
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1');
718+
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2');
719+
720+
it('does nothing when state has no beacons', () => {
721+
const emitSpy = jest.spyOn(state, 'emit');
722+
state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })]);
723+
expect(emitSpy).not.toHaveBeenCalled();
724+
});
725+
726+
it('does nothing when there are no events', () => {
727+
state.setStateEvents([beacon1, beacon2]);
728+
const emitSpy = jest.spyOn(state, 'emit').mockClear();
729+
state.processBeaconEvents([]);
730+
expect(emitSpy).not.toHaveBeenCalled();
731+
});
732+
733+
it('discards events for beacons that are not in state', () => {
734+
const location = makeBeaconEvent(userA, {
735+
beaconInfoId: 'some-other-beacon',
736+
});
737+
state.setStateEvents([beacon1, beacon2]);
738+
const emitSpy = jest.spyOn(state, 'emit').mockClear();
739+
state.processBeaconEvents([location]);
740+
expect(emitSpy).not.toHaveBeenCalled();
741+
});
742+
743+
it('adds locations to beacons', () => {
744+
const location1 = makeBeaconEvent(userA, {
745+
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
746+
});
747+
const location2 = makeBeaconEvent(userA, {
748+
beaconInfoId: '$beacon1', timestamp: Date.now() + 2,
749+
});
750+
const location3 = makeBeaconEvent(userB, {
751+
beaconInfoId: 'some-other-beacon',
752+
});
753+
754+
state.setStateEvents([beacon1, beacon2]);
755+
756+
expect(state.beacons.size).toEqual(2);
757+
758+
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1));
759+
const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations');
760+
761+
state.processBeaconEvents([location1, location2, location3]);
762+
763+
expect(addLocationsSpy).toHaveBeenCalledTimes(1);
764+
// only called with locations for beacon1
765+
expect(addLocationsSpy).toHaveBeenCalledWith([location1, location2]);
766+
});
767+
});
715768
});

spec/unit/utils.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import {
1010
prevString,
1111
simpleRetryOperation,
1212
stringToBase,
13+
sortEventsByLatestContentTimestamp,
1314
} from "../../src/utils";
1415
import { logger } from "../../src/logger";
16+
import { mkMessage } from "../test-utils/test-utils";
17+
import { makeBeaconEvent } from "../test-utils/beacon";
1518

1619
// TODO: Fix types throughout
1720

@@ -506,4 +509,30 @@ describe("utils", function() {
506509
});
507510
});
508511
});
512+
513+
describe('sortEventsByLatestContentTimestamp', () => {
514+
const roomId = '!room:server';
515+
const userId = '@user:server';
516+
const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true });
517+
// m.beacon events have timestamp in content
518+
const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 });
519+
const beaconEvent2 = makeBeaconEvent(userId, { timestamp: 1648804528558 });
520+
const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 });
521+
const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 });
522+
523+
it('sorts events with timestamps as later than events without', () => {
524+
expect(
525+
[beaconEvent4, eventWithoutContentTimestamp, beaconEvent1]
526+
.sort(utils.sortEventsByLatestContentTimestamp),
527+
).toEqual([
528+
beaconEvent1, beaconEvent4, eventWithoutContentTimestamp,
529+
]);
530+
});
531+
532+
it('sorts by content timestamps correctly', () => {
533+
expect(
534+
[beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp),
535+
).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]);
536+
});
537+
});
509538
});

src/client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ import { MediaHandler } from "./webrtc/mediaHandler";
180180
import { IRefreshTokenResponse } from "./@types/auth";
181181
import { TypedEventEmitter } from "./models/typed-event-emitter";
182182
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
183-
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
183+
import { MBeaconInfoEventContent, M_BEACON, M_BEACON_INFO } from "./@types/beacon";
184184

185185
export type Store = IStore;
186186
export type SessionStore = WebStorageSessionStore;
@@ -5169,6 +5169,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
51695169

51705170
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
51715171

5172+
this.processBeaconEvents(room, matrixEvents);
51725173
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
51735174
await this.processThreadEvents(room, threadedEvents, true);
51745175

@@ -5308,6 +5309,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
53085309
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
53095310
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
53105311
await this.processThreadEvents(timelineSet.room, threadedEvents, true);
5312+
this.processBeaconEvents(timelineSet.room, events);
53115313

53125314
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
53135315
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@@ -5438,6 +5440,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
54385440
// in the notification timeline set
54395441
const timelineSet = eventTimeline.getTimelineSet();
54405442
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
5443+
this.processBeaconEvents(timelineSet.room, matrixEvents);
54415444

54425445
// if we've hit the end of the timeline, we need to stop trying to
54435446
// paginate. We need to keep the 'forwards' token though, to make sure
@@ -5474,6 +5477,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
54745477
const timelineSet = eventTimeline.getTimelineSet();
54755478
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(matrixEvents);
54765479
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
5480+
this.processBeaconEvents(timelineSet.room, matrixEvents);
54775481
await this.processThreadEvents(room, threadedEvents, backwards);
54785482

54795483
// if we've hit the end of the timeline, we need to stop trying to
@@ -8851,6 +8855,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
88518855
await room.processThreadedEvents(threadedEvents, toStartOfTimeline);
88528856
}
88538857

8858+
public processBeaconEvents(
8859+
room: Room,
8860+
events?: MatrixEvent[],
8861+
): void {
8862+
if (!events?.length) {
8863+
return;
8864+
}
8865+
const beaconEvents = events.filter(event => M_BEACON.matches(event.getType()));
8866+
room.currentState.processBeaconEvents(beaconEvents);
8867+
}
8868+
88548869
/**
88558870
* Fetches the user_id of the configured access token.
88568871
*/

src/content-helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,18 @@ export const makeBeaconContent: MakeBeaconContent = (
261261
event_id: beaconInfoEventId,
262262
},
263263
});
264+
265+
export type BeaconLocationState = MLocationContent & {
266+
timestamp: number;
267+
};
268+
269+
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
270+
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
271+
const timestamp = M_TIMESTAMP.findIn<number>(content);
272+
273+
return {
274+
description,
275+
uri,
276+
timestamp,
277+
};
278+
};

0 commit comments

Comments
 (0)