From 3322b47b6d176ae5170622534b18757924d0f857 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 6 Apr 2022 08:49:09 -0400 Subject: [PATCH 01/22] Make self membership less prone to races (#2277) --- src/models/room.ts | 37 +++++++++++++++++++++---------------- src/sync.ts | 5 ----- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index 9acf3e81484..4a833598954 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2228,22 +2228,27 @@ export class Room extends TypedEventEmitter // set fake stripped state events if this is an invite room so logic remains // consistent elsewhere. const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); - if (membershipEvent && membershipEvent.getContent().membership === "invite") { - const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || []; - strippedStateEvents.forEach((strippedEvent) => { - const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); - if (!existingEvent) { - // set the fake stripped event instead - this.currentState.setStateEvents([new MatrixEvent({ - type: strippedEvent.type, - state_key: strippedEvent.state_key, - content: strippedEvent.content, - event_id: "$fake" + Date.now(), - room_id: this.roomId, - user_id: this.myUserId, // technically a lie - })]); - } - }); + if (membershipEvent) { + const membership = membershipEvent.getContent().membership; + this.updateMyMembership(membership); + + if (membership === "invite") { + const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || []; + strippedStateEvents.forEach((strippedEvent) => { + const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); + if (!existingEvent) { + // set the fake stripped event instead + this.currentState.setStateEvents([new MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId, // technically a lie + })]); + } + }); + } } const oldName = this.name; diff --git a/src/sync.ts b/src/sync.ts index 8ac3949ba59..337a2593d37 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1170,7 +1170,6 @@ export class SyncApi { stateEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); - room.updateMyMembership("invite"); }); // Handle joins @@ -1317,8 +1316,6 @@ export class SyncApi { client.emit(ClientEvent.Event, e); }); - room.updateMyMembership("join"); - // Decrypt only the last message in all rooms to make sure we can generate a preview // And decrypt all events after the recorded read receipt to ensure an accurate // notification count @@ -1352,8 +1349,6 @@ export class SyncApi { accountDataEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); - - room.updateMyMembership("leave"); }); // update the notification timeline, if appropriate. From dde4285cdf5f7a2e49bd90a39b690c8c38ac8fab Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 7 Apr 2022 13:46:50 +0100 Subject: [PATCH 02/22] Fix handling of threaded messages around edits & echoes (#2267) --- spec/integ/matrix-client-methods.spec.js | 27 +- spec/test-utils/test-utils.ts | 1 + spec/unit/matrix-client.spec.ts | 2 +- spec/unit/room.spec.ts | 314 ++++++++++++++--------- src/client.ts | 74 +----- src/event-mapper.ts | 5 +- src/models/event-timeline-set.ts | 4 +- src/models/event.ts | 5 - src/models/relations.ts | 2 +- src/models/room.ts | 183 ++++++++++--- src/models/thread.ts | 57 ++-- src/sync.ts | 29 +-- 12 files changed, 390 insertions(+), 313 deletions(-) diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 7b6a7be0a06..b56744cbabc 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -145,12 +145,14 @@ describe("MatrixClient", function() { describe("joinRoom", function() { it("should no-op if you've already joined a room", function() { const roomId = "!foo:bar"; - const room = new Room(roomId, userId); + const room = new Room(roomId, client, userId); + client.fetchRoomEvent = () => Promise.resolve({}); room.addLiveEvents([ utils.mkMembership({ user: userId, room: roomId, mship: "join", event: true, }), ]); + httpBackend.verifyNoOutstandingRequests(); store.storeRoom(room); client.joinRoom(roomId); httpBackend.verifyNoOutstandingRequests(); @@ -556,11 +558,14 @@ describe("MatrixClient", function() { }); describe("partitionThreadedEvents", function() { - const room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + let room; + beforeEach(() => { + room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + }); it("returns empty arrays when given an empty arrays", function() { const events = []; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([]); expect(threaded).toEqual([]); }); @@ -580,7 +585,7 @@ describe("MatrixClient", function() { // Vote has no threadId yet expect(eventPollResponseReference.threadId).toBeFalsy(); - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ // The message that was sent in a thread is missing @@ -613,7 +618,7 @@ describe("MatrixClient", function() { eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -640,7 +645,7 @@ describe("MatrixClient", function() { eventMessageInThread, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -667,7 +672,7 @@ describe("MatrixClient", function() { eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ eventPollStartThreadRoot, @@ -710,7 +715,7 @@ describe("MatrixClient", function() { eventMember, eventCreate, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ // The message that was sent in a thread is missing @@ -749,7 +754,7 @@ describe("MatrixClient", function() { threadedReactionRedaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, @@ -778,7 +783,7 @@ describe("MatrixClient", function() { replyToReply, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, @@ -805,7 +810,7 @@ describe("MatrixClient", function() { replyToThreadResponse, ]; - const [timeline, threaded] = client.partitionThreadedEvents(room, events); + const [timeline, threaded] = room.partitionThreadedEvents(events); expect(timeline).toEqual([ threadRootEvent, diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 24f7b9966ec..ab6b7d710fc 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -101,6 +101,7 @@ export function mkEvent(opts: IEventOpts): object | MatrixEvent { content: opts.content, unsigned: opts.unsigned || {}, event_id: "$" + Math.random() + "-" + Math.random(), + txn_id: "~" + Math.random(), redacts: opts.redacts, }; if (opts.skey !== undefined) { diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index c49bdccc673..b363daa2326 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -981,7 +981,7 @@ describe("MatrixClient", function() { expect(rootEvent.isThreadRoot).toBe(true); - const [roomEvents, threadEvents] = client.partitionThreadedEvents(room, [rootEvent]); + const [roomEvents, threadEvents] = room.partitionThreadedEvents([rootEvent]); expect(roomEvents).toHaveLength(1); expect(threadEvents).toHaveLength(1); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index faa73ba29b9..8cf41b5f5c4 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -35,6 +35,8 @@ import { Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; +import { emitPromise } from "../test-utils/test-utils"; +import { ThreadEvent } from "../../src/models/thread"; describe("Room", function() { const roomId = "!foo:bar"; @@ -44,8 +46,86 @@ describe("Room", function() { const userD = "@dorothy:bar"; let room; + const mkMessage = () => utils.mkMessage({ + event: true, + user: userA, + room: roomId, + }) as MatrixEvent; + + const mkReply = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Reply :: " + Math.random(), + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), + }, + }, + }, + }) as MatrixEvent; + + const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "* Edit of :: " + target.getId() + " :: " + salt, + "m.new_content": { + body: "Edit of :: " + target.getId() + " :: " + salt, + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: target.getId(), + }, + }, + }) as MatrixEvent; + + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }) as MatrixEvent; + + const mkReaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.Reaction, + user: userA, + room: roomId, + content: { + "m.relates_to": { + "rel_type": RelationType.Annotation, + "event_id": target.getId(), + "key": Math.random().toString(), + }, + }, + }) as MatrixEvent; + + const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: userA, + room: roomId, + redacts: target.getId(), + content: {}, + }) as MatrixEvent; + beforeEach(function() { - room = new Room(roomId, null, userA); + room = new Room(roomId, new TestClient(userA, "device").client, userA); // mock RoomStates room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); @@ -157,19 +237,18 @@ describe("Room", function() { expect(room.timeline[0]).toEqual(events[0]); }); - it("should emit 'Room.timeline' events", - function() { - let callCount = 0; - room.on("Room.timeline", function(event, emitRoom, toStart) { - callCount += 1; - expect(room.timeline.length).toEqual(callCount); - expect(event).toEqual(events[callCount - 1]); - expect(emitRoom).toEqual(room); - expect(toStart).toBeFalsy(); - }); - room.addLiveEvents(events); - expect(callCount).toEqual(2); + it("should emit 'Room.timeline' events", function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBeFalsy(); }); + room.addLiveEvents(events); + expect(callCount).toEqual(2); + }); it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function() { @@ -338,43 +417,42 @@ describe("Room", function() { expect(oldEv.sender).toEqual(oldSentinel); }); - it("should set event.target for new and old m.room.member events", - function() { - const sentinel = { - userId: userA, - membership: "join", - name: "Alice", - }; - const oldSentinel = { - userId: userA, - membership: "join", - name: "Old Alice", - }; - room.currentState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return sentinel; - } - return null; - }); - room.oldState.getSentinelMember.mockImplementation(function(uid) { - if (uid === userA) { - return oldSentinel; - } - return null; - }); - - const newEv = utils.mkMembership({ - room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent; - const oldEv = utils.mkMembership({ - room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }) as MatrixEvent; - room.addLiveEvents([newEv]); - expect(newEv.target).toEqual(sentinel); - room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); - expect(oldEv.target).toEqual(oldSentinel); + it("should set event.target for new and old m.room.member events", function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + room.currentState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.oldState.getSentinelMember.mockImplementation(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; }); + const newEv = utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }) as MatrixEvent; + const oldEv = utils.mkMembership({ + room: roomId, mship: "ban", user: userB, skey: userA, event: true, + }) as MatrixEvent; + room.addLiveEvents([newEv]); + expect(newEv.target).toEqual(sentinel); + room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + expect(oldEv.target).toEqual(oldSentinel); + }); + it("should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", function() { const events: MatrixEvent[] = [ @@ -406,7 +484,7 @@ describe("Room", function() { let events = null; beforeEach(function() { - room = new Room(roomId, null, null, { timelineSupport: timelineSupport }); + room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: timelineSupport }); // set events each time to avoid resusing Event objects (which // doesn't work because they get frozen) events = [ @@ -469,8 +547,7 @@ describe("Room", function() { expect(callCount).toEqual(1); }); - it("should " + (timelineSupport ? "remember" : "forget") + - " old timelines", function() { + it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", function() { room.addLiveEvents([events[0]]); expect(room.timeline.length).toEqual(1); const firstLiveTimeline = room.getLiveTimeline(); @@ -486,7 +563,7 @@ describe("Room", function() { describe("compareEventOrdering", function() { beforeEach(function() { - room = new Room(roomId, null, null, { timelineSupport: true }); + room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: true }); }); const events: MatrixEvent[] = [ @@ -673,7 +750,7 @@ describe("Room", function() { beforeEach(function() { // no mocking - room = new Room(roomId, null, userA); + room = new Room(roomId, new TestClient(userA).client, userA); }); describe("Room.recalculate => Stripped State Events", function() { @@ -1259,6 +1336,7 @@ describe("Room", function() { const client = (new TestClient( "@alice:example.com", "alicedevice", )).client; + client.supportsExperimentalThreads = () => true; const room = new Room(roomId, client, userA, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -1285,7 +1363,7 @@ describe("Room", function() { it("should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", function() { - room = new Room(roomId, null, userA, { + const room = new Room(roomId, new TestClient(userA).client, userA, { pendingEventOrdering: PendingEventOrdering.Chronological, }); const eventA = utils.mkMessage({ @@ -1504,7 +1582,7 @@ describe("Room", function() { describe("guessDMUserId", function() { it("should return first hero id", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.setSummary({ 'm.heroes': [userB], 'm.joined_member_count': 1, @@ -1513,7 +1591,7 @@ describe("Room", function() { expect(room.guessDMUserId()).toEqual(userB); }); it("should return first member that isn't self", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([utils.mkMembership({ user: userB, mship: "join", @@ -1523,7 +1601,7 @@ describe("Room", function() { expect(room.guessDMUserId()).toEqual(userB); }); it("should return self if only member present", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.guessDMUserId()).toEqual(userA); }); }); @@ -1542,12 +1620,12 @@ describe("Room", function() { describe("getDefaultRoomName", function() { it("should return 'Empty room' if a user is the only member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); it("should return a display name if one other member is in the room", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1562,7 +1640,7 @@ describe("Room", function() { }); it("should return a display name if one other member is banned", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1577,7 +1655,7 @@ describe("Room", function() { }); it("should return a display name if one other member is invited", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1592,7 +1670,7 @@ describe("Room", function() { }); it("should return 'Empty room (was User B)' if User B left the room", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1607,7 +1685,7 @@ describe("Room", function() { }); it("should return 'User B and User C' if in a room with two other users", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1626,7 +1704,7 @@ describe("Room", function() { }); it("should return 'User B and 2 others' if in a room with three other users", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1651,7 +1729,7 @@ describe("Room", function() { describe("io.element.functional_users", function() { it("should return a display name (default behaviour) if no one is marked as a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1673,7 +1751,7 @@ describe("Room", function() { }); it("should return a display name (default behaviour) if service members is a number (invalid)", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1697,7 +1775,7 @@ describe("Room", function() { }); it("should return a display name (default behaviour) if service members is a string (invalid)", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1719,7 +1797,7 @@ describe("Room", function() { }); it("should return 'Empty room' if the only other member is a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1741,7 +1819,7 @@ describe("Room", function() { }); it("should return 'User B' if User B is the only other member who isn't a functional member", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1767,7 +1845,7 @@ describe("Room", function() { }); it("should return 'Empty room' if all other members are functional members", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1793,7 +1871,7 @@ describe("Room", function() { }); it("should not break if an unjoined user is marked as a service user", function() { - const room = new Room(roomId, null, userA); + const room = new Room(roomId, new TestClient(userA).client, userA); room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -1858,71 +1936,51 @@ describe("Room", function() { expect(() => room.createThread(rootEvent, [])).not.toThrow(); }); - }); - describe("eventShouldLiveIn", () => { - const room = new Room(roomId, null, userA); + it("Edits update the lastReply event", async () => { + const client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + client.supportsExperimentalThreads = () => true; + room = new Room(roomId, client, userA); - const mkMessage = () => utils.mkMessage({ - event: true, - user: userA, - room: roomId, - }) as MatrixEvent; + const randomMessage = mkMessage(); + const threadRoot = mkMessage(); + const threadResponse = mkThreadResponse(threadRoot); + threadResponse.localTimestamp += 1000; + const threadResponseEdit = mkEdit(threadResponse); + threadResponseEdit.localTimestamp += 2000; - const mkReply = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Reply :: " + Math.random(), - "m.relates_to": { - "m.in_reply_to": { - "event_id": target.getId(), + client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse.event, + count: 2, + current_user_participated: true, + }, }, }, - }, - }) as MatrixEvent; + }); - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId(), - }, - "rel_type": "m.thread", - }, - }, - }) as MatrixEvent; + room.addLiveEvents([randomMessage, threadRoot, threadResponse]); + const thread = await emitPromise(room, ThreadEvent.New); - const mkReaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.Reaction, - user: userA, - room: roomId, - content: { - "m.relates_to": { - "rel_type": RelationType.Annotation, - "event_id": target.getId(), - "key": Math.random().toString(), - }, - }, - }) as MatrixEvent; + expect(thread.replyToEvent).toBe(threadResponse); + expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); - const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomRedaction, - user: userA, - room: roomId, - redacts: target.getId(), - content: {}, - }) as MatrixEvent; + room.addLiveEvents([threadResponseEdit]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); + }); + }); + + describe("eventShouldLiveIn", () => { + const client = new TestClient(userA).client; + client.supportsExperimentalThreads = () => true; + const room = new Room(roomId, client, userA); it("thread root and its relations&redactions should be in both", () => { const randomMessage = mkMessage(); diff --git a/src/client.ts b/src/client.ts index 11ec7fa4d56..982feb2bf3c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3771,9 +3771,8 @@ export class MatrixClient extends TypedEventEmitter { - const threadRoots = new Set(); - for (const event of events) { - if (event.isThreadRelation) { - threadRoots.add(event.relationEventId); - } - } - return threadRoots; - } - - public partitionThreadedEvents(room: Room, events: MatrixEvent[]): [ - timelineEvents: MatrixEvent[], - threadedEvents: MatrixEvent[], - ] { - // Indices to the events array, for readability - const ROOM = 0; - const THREAD = 1; - if (this.supportsExperimentalThreads()) { - const threadRoots = this.findThreadRoots(events); - return events.reduce((memo, event: MatrixEvent) => { - const { - shouldLiveInRoom, - shouldLiveInThread, - threadId, - } = room.eventShouldLiveIn(event, events, threadRoots); - - if (shouldLiveInRoom) { - memo[ROOM].push(event); - } - - if (shouldLiveInThread) { - event.setThreadId(threadId); - memo[THREAD].push(event); - } - - return memo; - }, [[], []]); - } else { - // When `experimentalThreadSupport` is disabled - // treat all events as timelineEvents - return [ - events, - [], - ]; - } - } - /** * @experimental */ @@ -8911,9 +8857,7 @@ export class MatrixClient extends TypedEventEmitter { - for (const event of threadedEvents) { - await room.addThreadedEvent(event, toStartOfTimeline); - } + await room.processThreadedEvents(threadedEvents, toStartOfTimeline); } /** diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 7b4f106f884..5349a565c83 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -45,8 +45,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned }); } - if (room?.threads.has(event.getId())) { - event.setThread(room.threads.get(event.getId())); + const thread = room?.findThreadForEvent(event); + if (thread) { + event.setThread(thread); } if (event.isEncrypted()) { diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 1fda0d977a4..13ea8c458f2 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -775,7 +775,7 @@ export class EventTimelineSet extends TypedEventEmitter(RelationType.Replace); - const minTs = replaceRelation && replaceRelation.origin_server_ts; + const minTs = replaceRelation?.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { if (event.getSender() !== this.targetEvent.getSender()) { diff --git a/src/models/room.ts b/src/models/room.ts index 4a833598954..35945e71fce 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -22,7 +22,7 @@ import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; -import { normalize } from "../utils"; +import { defer, normalize } from "../utils"; import { IEvent, IThreadBundledRelationship, MatrixEvent } from "./event"; import { EventStatus } from "./event-status"; import { RoomMember } from "./room-member"; @@ -213,6 +213,8 @@ export class Room extends TypedEventEmitter private getTypeWarning = false; private getVersionWarning = false; private membersPromise?: Promise; + // Map from threadId to pending Thread instance created by createThreadFetchRoot + private threadPromises = new Map>(); // XXX: These should be read-only /** @@ -1567,6 +1569,13 @@ export class Room extends TypedEventEmitter shouldLiveInThread: boolean; threadId?: string; } { + if (!this.client.supportsExperimentalThreads()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + // A thread root is always shown in both timelines if (event.isThreadRoot || roots?.has(event.getId())) { return { @@ -1581,7 +1590,7 @@ export class Room extends TypedEventEmitter return { shouldLiveInRoom: false, shouldLiveInThread: true, - threadId: event.relationEventId, + threadId: event.threadRootId, }; } @@ -1630,21 +1639,23 @@ export class Room extends TypedEventEmitter threadId: string, events?: MatrixEvent[], toStartOfTimeline?: boolean, - ): Promise { + ): Promise { let thread = this.getThread(threadId); if (!thread) { + const deferred = defer(); + this.threadPromises.set(threadId, deferred.promise); + let rootEvent = this.findEventById(threadId); // If the rootEvent does not exist in the local stores, then fetch it from the server. try { const eventData = await this.client.fetchRoomEvent(this.roomId, threadId); - - if (!rootEvent) { - rootEvent = new MatrixEvent(eventData); - } else { - rootEvent.setUnsigned(eventData.unsigned); - } + const mapper = this.client.getEventMapper(); + rootEvent = mapper(eventData); // will merge with existing event object if such is known + } catch (e) { + logger.error("Failed to fetch thread root to construct thread with", e); } finally { + this.threadPromises.delete(threadId); // The root event might be not be visible to the person requesting it. // If it wasn't fetched successfully the thread will work in "limited" mode and won't // benefit from all the APIs a homeserver can provide to enhance the thread experience @@ -1652,26 +1663,51 @@ export class Room extends TypedEventEmitter if (thread) { rootEvent.setThread(thread); } + deferred.resolve(thread); } } return thread; } + private async addThreadedEvents(events: MatrixEvent[], threadId: string, toStartOfTimeline = false): Promise { + let thread = this.getThread(threadId); + if (this.threadPromises.has(threadId)) { + thread = await this.threadPromises.get(threadId); + } + + if (thread) { + for (const event of events) { + await thread.addEvent(event, toStartOfTimeline); + } + } else { + thread = await this.createThreadFetchRoot(threadId, events, toStartOfTimeline); + } + + if (thread) { + this.emit(ThreadEvent.Update, thread); + } + } + /** - * Add an event to a thread's timeline. Will fire "Thread.update" + * Adds events to a thread's timeline. Will fire "Thread.update" * @experimental */ - public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise { - this.applyRedaction(event); - let thread = this.findThreadForEvent(event); - if (thread) { - await thread.addEvent(event, toStartOfTimeline); - } else { - thread = await this.createThreadFetchRoot(event.threadRootId, [event], toStartOfTimeline); + public async processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): Promise { + events.forEach(this.applyRedaction); + + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + for (const event of events) { + const { threadId } = this.eventShouldLiveIn(event); + if (!eventsByThread[threadId]) { + eventsByThread[threadId] = []; + } + eventsByThread[threadId].push(event); } - this.emit(ThreadEvent.Update, thread); + return Promise.all(Object.entries(eventsByThread).map(([threadId, events]) => ( + this.addThreadedEvents(events, threadId, toStartOfTimeline) + ))); } public createThread( @@ -1728,7 +1764,7 @@ export class Room extends TypedEventEmitter } } - private applyRedaction(event: MatrixEvent): void { + private applyRedaction = (event: MatrixEvent): void => { if (event.isRedaction()) { const redactId = event.event.redacts; @@ -1738,7 +1774,7 @@ export class Room extends TypedEventEmitter redactedEvent.makeRedacted(event); // If this is in the current state, replace it with the redacted version - if (redactedEvent.getStateKey()) { + if (redactedEvent.isState()) { const currentStateEvent = this.currentState.getStateEvents( redactedEvent.getType(), redactedEvent.getStateKey(), @@ -1772,19 +1808,9 @@ export class Room extends TypedEventEmitter // clients can say "so and so redacted an event" if they wish to. Also // this may be needed to trigger an update. } - } + }; - /** - * Add an event to the end of this room's live timelines. Will fire - * "Room.timeline". - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - * @fires module:client~MatrixClient#event:"Room.timeline" - * @private - */ - private addLiveEvent(event: MatrixEvent, duplicateStrategy?: DuplicateStrategy, fromCache = false): void { + private processLiveEvent(event: MatrixEvent): Promise { this.applyRedaction(event); // Implement MSC3531: hiding messages. @@ -1804,7 +1830,19 @@ export class Room extends TypedEventEmitter return; } } + } + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + * @fires module:client~MatrixClient#event:"Room.timeline" + * @private + */ + private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void { // add to our timeline sets for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); @@ -1998,10 +2036,7 @@ export class Room extends TypedEventEmitter const newEventId = remoteEvent.getId(); const oldStatus = localEvent.status; - logger.debug( - `Got remote echo for event ${oldEventId} -> ${newEventId} ` + - `old status ${oldStatus}`, - ); + logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`); // no longer pending delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; @@ -2167,10 +2202,84 @@ export class Room extends TypedEventEmitter } } + const threadRoots = this.findThreadRoots(events); + const threadInfos = events.map(e => this.eventShouldLiveIn(e, events, threadRoots)); + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + for (let i = 0; i < events.length; i++) { // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". - this.addLiveEvent(events[i], duplicateStrategy, fromCache); + this.processLiveEvent(events[i]); + + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId, + } = threadInfos[i]; + + if (shouldLiveInThread) { + if (!eventsByThread[threadId]) { + eventsByThread[threadId] = []; + } + eventsByThread[threadId].push(events[i]); + } + + if (shouldLiveInRoom) { + this.addLiveEvent(events[i], duplicateStrategy, fromCache); + } + } + + Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => { + this.addThreadedEvents(threadEvents, threadId, false); + }); + } + + public partitionThreadedEvents(events: MatrixEvent[]): [ + timelineEvents: MatrixEvent[], + threadedEvents: MatrixEvent[], + ] { + // Indices to the events array, for readability + const ROOM = 0; + const THREAD = 1; + if (this.client.supportsExperimentalThreads()) { + const threadRoots = this.findThreadRoots(events); + return events.reduce((memo, event: MatrixEvent) => { + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId, + } = this.eventShouldLiveIn(event, events, threadRoots); + + if (shouldLiveInRoom) { + memo[ROOM].push(event); + } + + if (shouldLiveInThread) { + event.setThreadId(threadId); + memo[THREAD].push(event); + } + + return memo; + }, [[], []]); + } else { + // When `experimentalThreadSupport` is disabled treat all events as timelineEvents + return [ + events, + [], + ]; + } + } + + /** + * Given some events, find the IDs of all the thread roots that are referred to by them. + */ + private findThreadRoots(events: MatrixEvent[]): Set { + const threadRoots = new Set(); + for (const event of events) { + if (event.isThreadRelation) { + threadRoots.add(event.relationEventId); + } } + return threadRoots; } /** diff --git a/src/models/thread.ts b/src/models/thread.ts index 7879cc89f9c..3b13c1be87b 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -94,15 +94,15 @@ export class Thread extends TypedEventEmitter { RoomEvent.TimelineReset, ]); + this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); + this.timelineSet.on(RoomEvent.Timeline, this.onEcho); + // If we weren't able to find the root event, it's probably missing, // and we define the thread ID from one of the thread relation this.id = rootEvent?.getId() ?? opts?.initialEvents?.find(event => event.isThreadRelation)?.relationEventId; this.initialiseThread(this.rootEvent); opts?.initialEvents?.forEach(event => this.addEvent(event, false)); - - this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); - this.room.on(RoomEvent.Timeline, this.onEcho); } public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void { @@ -115,6 +115,26 @@ export class Thread extends TypedEventEmitter { } private onEcho = (event: MatrixEvent) => { + // There is a risk that the `localTimestamp` approximation will not be accurate + // when threads are used over federation. That could result in the reply + // count value drifting away from the value returned by the server + const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); + if (!this.lastEvent || (isThreadReply + && (event.getId() !== this.lastEvent.getId()) + && (event.localTimestamp > this.lastEvent.localTimestamp)) + ) { + this.lastEvent = event; + if (this.lastEvent.getId() !== this.id) { + // This counting only works when server side support is enabled as we started the counting + // from the value returned within the bundled relationship + if (Thread.hasServerSideSupport) { + this.replyCount++; + } + + this.emit(ThreadEvent.NewReply, this, event); + } + } + if (this.timelineSet.eventIdToTimeline(event.getId())) { this.emit(ThreadEvent.Update, this); } @@ -125,15 +145,6 @@ export class Thread extends TypedEventEmitter { } private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { - if (event.getUnsigned().transaction_id) { - const existingEvent = this.room.getEventForTxnId(event.getUnsigned().transaction_id); - if (existingEvent) { - // remote echo of an event we sent earlier - this.room.handleRemoteEcho(event, existingEvent); - return; - } - } - if (!this.findEventById(event.getId())) { this.timelineSet.addEventToTimeline( event, @@ -177,33 +188,13 @@ export class Thread extends TypedEventEmitter { this._currentUserParticipated = true; } - const isThreadReply = event.getRelation()?.rel_type === THREAD_RELATION_TYPE.name; + const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); // If no thread support exists we want to count all thread relation // added as a reply. We can't rely on the bundled relationships count if (!Thread.hasServerSideSupport && isThreadReply) { this.replyCount++; } - // There is a risk that the `localTimestamp` approximation will not be accurate - // when threads are used over federation. That could results in the reply - // count value drifting away from the value returned by the server - if (!this.lastEvent || (isThreadReply - && (event.getId() !== this.lastEvent.getId()) - && (event.localTimestamp > this.lastEvent.localTimestamp)) - ) { - this.lastEvent = event; - if (this.lastEvent.getId() !== this.id) { - // This counting only works when server side support is enabled - // as we started the counting from the value returned in the - // bundled relationship - if (Thread.hasServerSideSupport) { - this.replyCount++; - } - - this.emit(ThreadEvent.NewReply, this, event); - } - } - this.emit(ThreadEvent.Update, this); } diff --git a/src/sync.ts b/src/sync.ts index 337a2593d37..c6e36e0f9b6 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1635,36 +1635,9 @@ export class SyncApi { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - const [mainTimelineEvents, threadedEvents] = this.client.partitionThreadedEvents(room, timelineEventList || []); - room.addLiveEvents(mainTimelineEvents, null, fromCache); - await this.processThreadEvents(room, threadedEvents, false); + room.addLiveEvents(timelineEventList || [], null, fromCache); } - /** - * @experimental - */ - private processThreadEvents( - room: Room, - threadedEvents: MatrixEvent[], - toStartOfTimeline: boolean, - ): Promise { - return this.client.processThreadEvents(room, threadedEvents, toStartOfTimeline); - } - - // extractRelatedEvents(event: MatrixEvent, events: MatrixEvent[], relatedEvents: MatrixEvent[] = []): MatrixEvent[] { - // relatedEvents.push(event); - - // const parentEventId = event.getAssociatedId(); - // const parentEventIndex = events.findIndex(event => event.getId() === parentEventId); - - // if (parentEventIndex > -1) { - // const [relatedEvent] = events.splice(parentEventIndex, 1); - // return this.extractRelatedEvents(relatedEvent, events, relatedEvents); - // } else { - // return relatedEvents; - // } - // } - /** * Takes a list of timelineEvents and adds and adds to notifEvents * as appropriate. From 781fdf4fdc45c17b5c37a94e7e0b8ec588ea1218 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 8 Apr 2022 10:50:06 +0200 Subject: [PATCH 03/22] Live location sharing - update beacon_info implementation to latest MSC (#2281) * remove M_BEACON_INFO_VARIABLE Signed-off-by: Kerry Archibald * create beacon_info events with non-variable event type Signed-off-by: Kerry Archibald * remove isBeaconInfoEventType Signed-off-by: Kerry Archibald * refer to msc3673 instead of msc3489 Signed-off-by: Kerry Archibald * remove event type suffix Signed-off-by: Kerry Archibald * update beacon identifier to use state key Signed-off-by: Kerry Archibald * fix beacon spec Signed-off-by: Kerry Archibald * fix room-state tests Signed-off-by: Kerry Archibald * add beacon identifier Signed-off-by: Kerry Archibald * dont allow update to older beacon event Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald * unnest beacon_info content Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald * check redaction event id Signed-off-by: Kerry Archibald --- spec/test-utils/beacon.ts | 5 ++- spec/unit/content-helpers.spec.ts | 9 ++--- spec/unit/matrix-client.spec.ts | 10 ++--- spec/unit/models/beacon.spec.ts | 63 +++++++++++++++++-------------- spec/unit/room-state.spec.js | 30 +++++++-------- src/@types/beacon.ts | 28 +++++--------- src/client.ts | 15 ++------ src/content-helpers.ts | 18 ++++----- src/models/beacon.ts | 21 +++++++---- src/models/room-state.ts | 26 ++++++++----- 10 files changed, 108 insertions(+), 117 deletions(-) diff --git a/spec/test-utils/beacon.ts b/spec/test-utils/beacon.ts index 84fe41cdf27..0823cca0c72 100644 --- a/spec/test-utils/beacon.ts +++ b/spec/test-utils/beacon.ts @@ -42,7 +42,6 @@ export const makeBeaconInfoEvent = ( roomId: string, contentProps: Partial = {}, eventId?: string, - eventTypeSuffix?: string, ): MatrixEvent => { const { timeout, isLive, description, assetType, @@ -51,12 +50,14 @@ export const makeBeaconInfoEvent = ( ...contentProps, }; const event = new MatrixEvent({ - type: `${M_BEACON_INFO.name}.${sender}.${eventTypeSuffix || Date.now()}`, + type: M_BEACON_INFO.name, room_id: roomId, state_key: sender, content: makeBeaconInfoContent(timeout, isLive, description, assetType), }); + event.event.origin_server_ts = Date.now(); + // live beacons use the beacon_info event id // set or default this event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`); diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 3430bf4c2c1..71b7344ed6a 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -16,7 +16,6 @@ limitations under the License. import { REFERENCE_RELATION } from "matrix-events-sdk"; -import { M_BEACON_INFO } from "../../src/@types/beacon"; import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; @@ -36,11 +35,9 @@ describe('Beacon content helpers', () => { 'nice beacon_info', LocationAssetType.Pin, )).toEqual({ - [M_BEACON_INFO.name]: { - description: 'nice beacon_info', - timeout: 1234, - live: true, - }, + description: 'nice beacon_info', + timeout: 1234, + live: true, [M_TIMESTAMP.name]: mockDateNow, [M_ASSET.name]: { type: LocationAssetType.Pin, diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index b363daa2326..b9b0081135e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -999,10 +999,10 @@ describe("MatrixClient", function() { }); it("creates new beacon info", async () => { - await client.unstable_createLiveBeacon(roomId, content, '123'); + await client.unstable_createLiveBeacon(roomId, content); // event type combined - const expectedEventType = `${M_BEACON_INFO.name}.${userId}.123`; + const expectedEventType = M_BEACON_INFO.name; const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; expect(callback).toBeFalsy(); expect(method).toBe('PUT'); @@ -1015,15 +1015,13 @@ describe("MatrixClient", function() { }); it("updates beacon info with specific event type", async () => { - const eventType = `${M_BEACON_INFO.name}.${userId}.456`; - - await client.unstable_setLiveBeacon(roomId, eventType, content); + await client.unstable_setLiveBeacon(roomId, content); // event type combined const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0]; expect(path).toEqual( `/rooms/${encodeURIComponent(roomId)}/state/` + - `${encodeURIComponent(eventType)}/${encodeURIComponent(userId)}`, + `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`, ); expect(requestContent).toEqual(content); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 0e39e7c6961..a037d905dd7 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,11 +14,8 @@ 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"; @@ -57,27 +54,9 @@ describe('Beacon', () => { }); }); - 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 userId2 = '@user2:server.org'; const roomId = '$room:server.org'; // 14.03.2022 16:15 const now = 1647270879403; @@ -88,6 +67,7 @@ describe('Beacon', () => { // without timeout of 3 hours let liveBeaconEvent; let notLiveBeaconEvent; + let user2BeaconEvent; const advanceDateAndTime = (ms: number) => { // bc liveness check uses Date.now we have to advance this mock @@ -107,14 +87,21 @@ describe('Beacon', () => { isLive: true, }, '$live123', - '$live123', ); notLiveBeaconEvent = makeBeaconInfoEvent( userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$dead123', - '$dead123', + ); + user2BeaconEvent = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS * 3, + isLive: true, + }, + '$user2live123', ); // back to now @@ -133,7 +120,7 @@ describe('Beacon', () => { expect(beacon.isLive).toEqual(true); expect(beacon.beaconInfoOwner).toEqual(userId); expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType()); - expect(beacon.identifier).toEqual(liveBeaconEvent.getType()); + expect(beacon.identifier).toEqual(`${roomId}_${userId}`); expect(beacon.beaconInfo).toBeTruthy(); }); @@ -171,8 +158,27 @@ describe('Beacon', () => { expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); - expect(() => beacon.update(notLiveBeaconEvent)).toThrow(); - expect(beacon.isLive).toEqual(true); + expect(() => beacon.update(user2BeaconEvent)).toThrow(); + // didnt update + expect(beacon.identifier).toEqual(`${roomId}_${userId}`); + }); + + it('does not update with an older event', () => { + const beacon = new Beacon(liveBeaconEvent); + const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); + expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); + + const oldUpdateEvent = makeBeaconInfoEvent( + userId, + roomId, + ); + // less than the original event + oldUpdateEvent.event.origin_server_ts = liveBeaconEvent.event.origin_server_ts - 1000; + + beacon.update(oldUpdateEvent); + // didnt update + expect(emitSpy).not.toHaveBeenCalled(); + expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); }); it('updates event', () => { @@ -182,7 +188,7 @@ describe('Beacon', () => { expect(beacon.isLive).toEqual(true); const updatedBeaconEvent = makeBeaconInfoEvent( - userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123', '$live123'); + userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123'); beacon.update(updatedBeaconEvent); expect(beacon.isLive).toEqual(false); @@ -200,7 +206,6 @@ describe('Beacon', () => { roomId, { timeout: HOUR_MS * 3, isLive: false }, beacon.beaconInfoId, - '$live123', ); beacon.update(updatedBeaconEvent); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index fa00d21fc9a..a96ff989c45 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -2,7 +2,7 @@ import * as utils from "../test-utils/test-utils"; import { makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; -import { BeaconEvent } from "../../src/models/beacon"; +import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -260,7 +260,7 @@ describe("RoomState", function() { state.setStateEvents([beaconEvent]); expect(state.beacons.size).toEqual(1); - const beaconInstance = state.beacons.get(beaconEvent.getType()); + const beaconInstance = state.beacons.get(`${roomId}_${userA}`); expect(beaconInstance).toBeTruthy(); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); }); @@ -275,49 +275,49 @@ describe("RoomState", function() { // no beacon added expect(state.beacons.size).toEqual(0); - expect(state.beacons.get(redactedBeaconEvent.getType)).toBeFalsy(); + expect(state.beacons.get(getBeaconInfoIdentifier(redactedBeaconEvent))).toBeFalsy(); // no new beacon emit expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy(); }); it('updates existing beacon info events in state', () => { const beaconId = '$beacon1'; - const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); - const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId, beaconId); + const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); + const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId); state.setStateEvents([beaconEvent]); - const beaconInstance = state.beacons.get(beaconEvent.getType()); + const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); expect(beaconInstance.isLive).toEqual(true); state.setStateEvents([updatedBeaconEvent]); // same Beacon - expect(state.beacons.get(beaconEvent.getType())).toBe(beaconInstance); + expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance); // updated liveness - expect(state.beacons.get(beaconEvent.getType()).isLive).toEqual(false); + expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent)).isLive).toEqual(false); }); it('destroys and removes redacted beacon events', () => { const beaconId = '$beacon1'; - const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); - const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId); - const redactionEvent = { event: { type: 'm.room.redaction' } }; + const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); + const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); + const redactionEvent = { event: { type: 'm.room.redaction', redacts: beaconEvent.getId() } }; redactedBeaconEvent.makeRedacted(redactionEvent); state.setStateEvents([beaconEvent]); - const beaconInstance = state.beacons.get(beaconEvent.getType()); + const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); const destroySpy = jest.spyOn(beaconInstance, 'destroy'); expect(beaconInstance.isLive).toEqual(true); state.setStateEvents([redactedBeaconEvent]); expect(destroySpy).toHaveBeenCalled(); - expect(state.beacons.get(beaconEvent.getType())).toBe(undefined); + expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined); }); it('updates live beacon ids once after setting state events', () => { - const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1', '$beacon1'); - const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2', '$beacon2'); + const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1'); + const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, '$beacon2'); const emitSpy = jest.spyOn(state, 'emit'); diff --git a/src/@types/beacon.ts b/src/@types/beacon.ts index adf033daa24..6da17061e61 100644 --- a/src/@types/beacon.ts +++ b/src/@types/beacon.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EitherAnd, RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk"; +import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk"; import { UnstableValue } from "../NamespacedValue"; import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; /** - * Beacon info and beacon event types as described in MSC3489 - * https://github.com/matrix-org/matrix-spec-proposals/pull/3489 + * Beacon info and beacon event types as described in MSC3672 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 */ /** @@ -60,16 +60,11 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; * } */ -/** - * Variable event type for m.beacon_info - */ -export const M_BEACON_INFO_VARIABLE = new UnstableValue("m.beacon_info.*", "org.matrix.msc3489.beacon_info.*"); - /** * Non-variable type for m.beacon_info event content */ -export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3489.beacon_info"); -export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3489.beacon"); +export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info"); +export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon"); export type MBeaconInfoContent = { description?: string; @@ -80,16 +75,11 @@ export type MBeaconInfoContent = { live?: boolean; }; -export type MBeaconInfoEvent = EitherAnd< - { [M_BEACON_INFO.name]: MBeaconInfoContent }, - { [M_BEACON_INFO.altName]: MBeaconInfoContent } ->; - /** * m.beacon_info Event example from the spec - * https://github.com/matrix-org/matrix-spec-proposals/pull/3489 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 * { - "type": "m.beacon_info.@matthew:matrix.org.1", + "type": "m.beacon_info", "state_key": "@matthew:matrix.org", "content": { "m.beacon_info": { @@ -108,7 +98,7 @@ export type MBeaconInfoEvent = EitherAnd< * m.beacon_info.* event content */ export type MBeaconInfoEventContent = & - MBeaconInfoEvent & + MBeaconInfoContent & // creation timestamp of the beacon on the client MTimestampEvent & // the type of asset being tracked as per MSC3488 @@ -116,7 +106,7 @@ export type MBeaconInfoEventContent = & /** * m.beacon event example - * https://github.com/matrix-org/matrix-spec-proposals/pull/3489 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3672 * * { "type": "m.beacon", diff --git a/src/client.ts b/src/client.ts index 982feb2bf3c..1ae008dba2a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -180,7 +180,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"; +import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; export type Store = IStore; export type SessionStore = WebStorageSessionStore; @@ -3649,39 +3649,30 @@ export class MatrixClient extends TypedEventEmitter ({ - [M_BEACON_INFO.name]: { - description, - timeout, - live: isLive, - }, + description, + timeout, + live: isLive, [M_TIMESTAMP.name]: timestamp || Date.now(), [M_ASSET.name]: { type: assetType ?? LocationAssetType.Self, @@ -227,7 +225,7 @@ export type BeaconInfoState = MBeaconInfoContent & { * Flatten beacon info event content */ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => { - const { description, timeout, live } = M_BEACON_INFO.findIn(content); + const { description, timeout, live } = content; const { type: assetType } = M_ASSET.findIn(content); const timestamp = M_TIMESTAMP.findIn(content); @@ -243,14 +241,14 @@ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): Beacon export type MakeBeaconContent = ( uri: string, timestamp: number, - beaconInfoId: string, + beaconInfoEventId: string, description?: string, ) => MBeaconEventContent; export const makeBeaconContent: MakeBeaconContent = ( uri, timestamp, - beaconInfoId, + beaconInfoEventId, description, ) => ({ [M_LOCATION.name]: { @@ -260,6 +258,6 @@ export const makeBeaconContent: MakeBeaconContent = ( [M_TIMESTAMP.name]: timestamp, "m.relates_to": { rel_type: REFERENCE_RELATION.name, - event_id: beaconInfoId, + event_id: beaconInfoEventId, }, }); diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 329fc04e6bd..70cb67f7a13 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -14,7 +14,6 @@ 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"; @@ -38,11 +37,13 @@ export const isTimestampInDuration = ( 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); +// beacon info events are uniquely identified by +// `_` +export type BeaconIdentifier = string; +export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier => + `${event.getRoomId()}_${event.getStateKey()}`; -// https://github.com/matrix-org/matrix-spec-proposals/pull/3489 +// https://github.com/matrix-org/matrix-spec-proposals/pull/3672 export class Beacon extends TypedEventEmitter, BeaconEventHandlerMap> { public readonly roomId: string; private _beaconInfo: BeaconInfoState; @@ -61,8 +62,8 @@ export class Beacon extends TypedEventEmitter public events = new Map>(); // Map> public paginationToken: string = null; - public readonly beacons = new Map(); - private liveBeaconIds: string[] = []; + public readonly beacons = new Map(); + private liveBeaconIds: BeaconIdentifier[] = []; /** * Construct room state. @@ -330,7 +333,7 @@ export class RoomState extends TypedEventEmitter return; } - if (isBeaconInfoEventType(event.getType())) { + if (M_BEACON_INFO.matches(event.getType())) { this.setBeacon(event); } @@ -437,12 +440,15 @@ export class RoomState extends TypedEventEmitter * @experimental */ private setBeacon(event: MatrixEvent): void { - if (this.beacons.has(event.getType())) { - const beacon = this.beacons.get(event.getType()); + const beaconIdentifier = getBeaconInfoIdentifier(event); + if (this.beacons.has(beaconIdentifier)) { + const beacon = this.beacons.get(beaconIdentifier); if (event.isRedacted()) { - beacon.destroy(); - this.beacons.delete(event.getType()); + if (beacon.beaconInfoId === event.getRedactionEvent()?.['redacts']) { + beacon.destroy(); + this.beacons.delete(beaconIdentifier); + } return; } @@ -464,7 +470,7 @@ export class RoomState extends TypedEventEmitter this.emit(BeaconEvent.New, event, beacon); beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); - this.beacons.set(beacon.beaconInfoEventType, beacon); + this.beacons.set(beacon.identifier, beacon); } /** @@ -477,7 +483,7 @@ export class RoomState extends TypedEventEmitter const prevHasLiveBeacons = !!this.liveBeaconIds?.length; this.liveBeaconIds = Array.from(this.beacons.values()) .filter(beacon => beacon.isLive) - .map(beacon => beacon.beaconInfoId); + .map(beacon => beacon.identifier); const hasLiveBeacons = !!this.liveBeaconIds.length; if (prevHasLiveBeacons !== hasLiveBeacons) { From 6d0f4e537ea52693010a6ab0ebd0ff4a47e8c084 Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 8 Apr 2022 10:34:01 +0100 Subject: [PATCH 04/22] Fix notification panel not loading (#2283) --- src/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1ae008dba2a..bb263ec5322 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5434,10 +5434,10 @@ export class MatrixClient extends TypedEventEmitter Date: Fri, 8 Apr 2022 13:26:05 +0200 Subject: [PATCH 05/22] Live location sharing - Aggregate beacon locations on beacons (#2268) * add timestamp sorting util Signed-off-by: Kerry Archibald * basic wiring Signed-off-by: Kerry Archibald * quick handle for redacted beacons Signed-off-by: Kerry Archibald * remove fdescribe Signed-off-by: Kerry Archibald * test adding locations Signed-off-by: Kerry Archibald * tidy comments Signed-off-by: Kerry Archibald * test client Signed-off-by: Kerry Archibald * fix monitorLiveness for update Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald --- spec/unit/matrix-client.spec.ts | 30 +++++++++++ spec/unit/models/beacon.spec.ts | 90 ++++++++++++++++++++++++++++++++- spec/unit/room-state.spec.js | 55 +++++++++++++++++++- spec/unit/utils.spec.ts | 29 +++++++++++ src/client.ts | 17 ++++++- src/content-helpers.ts | 15 ++++++ src/models/beacon.ts | 57 +++++++++++++++++++-- src/models/room-state.ts | 33 ++++++++++++ src/sync.ts | 1 + src/utils.ts | 14 +++++ 10 files changed, 334 insertions(+), 7 deletions(-) diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index b9b0081135e..cb782330a88 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -33,6 +33,7 @@ import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; import { Room } from "../../src"; +import { makeBeaconEvent } from "../test-utils/beacon"; jest.useFakeTimers(); @@ -1025,5 +1026,34 @@ describe("MatrixClient", function() { ); expect(requestContent).toEqual(content); }); + + describe('processBeaconEvents()', () => { + it('does nothing when events is falsy', () => { + const room = new Room(roomId, client, userId); + const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + + client.processBeaconEvents(room, undefined); + expect(roomStateProcessSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when events is of length 0', () => { + const room = new Room(roomId, client, userId); + const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + + client.processBeaconEvents(room, []); + expect(roomStateProcessSpy).not.toHaveBeenCalled(); + }); + + it('calls room states processBeaconEvents with m.beacon events', () => { + const room = new Room(roomId, client, userId); + const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents'); + + const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true }); + const beaconEvent = makeBeaconEvent(userId); + + client.processBeaconEvents(room, [messageEvent, beaconEvent]); + expect(roomStateProcessSpy).toHaveBeenCalledWith([beaconEvent]); + }); + }); }); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index a037d905dd7..dc4058d1ce4 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -19,7 +19,7 @@ import { Beacon, BeaconEvent, } from "../../../src/models/beacon"; -import { makeBeaconInfoEvent } from "../../test-utils/beacon"; +import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon"; jest.useFakeTimers(); @@ -282,5 +282,93 @@ describe('Beacon', () => { expect(emitSpy).toHaveBeenCalledTimes(1); }); }); + + describe('addLocations', () => { + it('ignores locations when beacon is not live', () => { + const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false })); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }), + ]); + + expect(beacon.latestLocationState).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('ignores locations outside the beacon live duration', () => { + const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + // beacon has now + 60000 live period + makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 100000 }), + ]); + + expect(beacon.latestLocationState).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('sets latest location state to most recent location', () => { + const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); + const emitSpy = jest.spyOn(beacon, 'emit'); + + const locations = [ + // older + makeBeaconEvent( + userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 }, + ), + // newer + makeBeaconEvent( + userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 }, + ), + // not valid + makeBeaconEvent( + userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 }, + ), + ]; + + beacon.addLocations(locations); + + const expectedLatestLocation = { + description: undefined, + timestamp: now + 10000, + uri: 'geo:bar', + }; + + // the newest valid location + expect(beacon.latestLocationState).toEqual(expectedLatestLocation); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation); + }); + + it('ignores locations that are less recent that the current latest location', () => { + const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); + + const olderLocation = makeBeaconEvent( + userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 }, + ); + const newerLocation = makeBeaconEvent( + userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 }, + ); + + beacon.addLocations([newerLocation]); + // latest location set to newerLocation + expect(beacon.latestLocationState).toEqual(expect.objectContaining({ + uri: 'geo:bar', + })); + + const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); + + // add older location + beacon.addLocations([olderLocation]); + + // no change + expect(beacon.latestLocationState).toEqual(expect.objectContaining({ + uri: 'geo:bar', + })); + // no emit + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index a96ff989c45..261f7572d91 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,5 +1,5 @@ import * as utils from "../test-utils/test-utils"; -import { makeBeaconInfoEvent } from "../test-utils/beacon"; +import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; @@ -712,4 +712,57 @@ describe("RoomState", function() { expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false); }); }); + + describe('processBeaconEvents', () => { + const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1'); + const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2'); + + it('does nothing when state has no beacons', () => { + const emitSpy = jest.spyOn(state, 'emit'); + state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })]); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when there are no events', () => { + state.setStateEvents([beacon1, beacon2]); + const emitSpy = jest.spyOn(state, 'emit').mockClear(); + state.processBeaconEvents([]); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('discards events for beacons that are not in state', () => { + const location = makeBeaconEvent(userA, { + beaconInfoId: 'some-other-beacon', + }); + state.setStateEvents([beacon1, beacon2]); + const emitSpy = jest.spyOn(state, 'emit').mockClear(); + state.processBeaconEvents([location]); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('adds locations to beacons', () => { + const location1 = makeBeaconEvent(userA, { + beaconInfoId: '$beacon1', timestamp: Date.now() + 1, + }); + const location2 = makeBeaconEvent(userA, { + beaconInfoId: '$beacon1', timestamp: Date.now() + 2, + }); + const location3 = makeBeaconEvent(userB, { + beaconInfoId: 'some-other-beacon', + }); + + state.setStateEvents([beacon1, beacon2]); + + expect(state.beacons.size).toEqual(2); + + const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)); + const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations'); + + state.processBeaconEvents([location1, location2, location3]); + + expect(addLocationsSpy).toHaveBeenCalledTimes(1); + // only called with locations for beacon1 + expect(addLocationsSpy).toHaveBeenCalledWith([location1, location2]); + }); + }); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index cd9b1b5b5e6..340acf92d2f 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -10,8 +10,11 @@ import { prevString, simpleRetryOperation, stringToBase, + sortEventsByLatestContentTimestamp, } from "../../src/utils"; import { logger } from "../../src/logger"; +import { mkMessage } from "../test-utils/test-utils"; +import { makeBeaconEvent } from "../test-utils/beacon"; // TODO: Fix types throughout @@ -506,4 +509,30 @@ describe("utils", function() { }); }); }); + + describe('sortEventsByLatestContentTimestamp', () => { + const roomId = '!room:server'; + const userId = '@user:server'; + const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true }); + // m.beacon events have timestamp in content + const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 }); + const beaconEvent2 = makeBeaconEvent(userId, { timestamp: 1648804528558 }); + const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 }); + const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 }); + + it('sorts events with timestamps as later than events without', () => { + expect( + [beaconEvent4, eventWithoutContentTimestamp, beaconEvent1] + .sort(utils.sortEventsByLatestContentTimestamp), + ).toEqual([ + beaconEvent1, beaconEvent4, eventWithoutContentTimestamp, + ]); + }); + + it('sorts by content timestamps correctly', () => { + expect( + [beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp), + ).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]); + }); + }); }); diff --git a/src/client.ts b/src/client.ts index bb263ec5322..7ec65d27db6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -180,7 +180,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 } from "./@types/beacon"; +import { MBeaconInfoEventContent, M_BEACON, M_BEACON_INFO } from "./@types/beacon"; export type Store = IStore; export type SessionStore = WebStorageSessionStore; @@ -5169,6 +5169,7 @@ export class MatrixClient extends TypedEventEmitter M_BEACON.matches(event.getType())); + room.currentState.processBeaconEvents(beaconEvents); + } + /** * Fetches the user_id of the configured access token. */ diff --git a/src/content-helpers.ts b/src/content-helpers.ts index e533d9355b7..383b9b34396 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -261,3 +261,18 @@ export const makeBeaconContent: MakeBeaconContent = ( event_id: beaconInfoEventId, }, }); + +export type BeaconLocationState = MLocationContent & { + timestamp: number; +}; + +export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => { + const { description, uri } = M_LOCATION.findIn(content); + const timestamp = M_TIMESTAMP.findIn(content); + + return { + description, + uri, + timestamp, + }; +}; diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 70cb67f7a13..caea773275d 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -14,21 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { BeaconInfoState, parseBeaconInfoContent } from "../content-helpers"; +import { MBeaconEventContent } from "../@types/beacon"; +import { M_TIMESTAMP } from "../@types/location"; +import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers"; import { MatrixEvent } from "../matrix"; +import { sortEventsByLatestContentTimestamp } from "../utils"; import { TypedEventEmitter } from "./typed-event-emitter"; export enum BeaconEvent { New = "Beacon.new", Update = "Beacon.update", LivenessChange = "Beacon.LivenessChange", - Destroy = "Destroy", + Destroy = "Beacon.Destroy", + LocationUpdate = "Beacon.LocationUpdate", } export type BeaconEventHandlerMap = { [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void; [BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void; [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; + [BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void; + [BeaconEvent.Destroy]: (beaconIdentifier: string) => void; }; export const isTimestampInDuration = ( @@ -49,6 +55,7 @@ export class Beacon extends TypedEventEmitter 1) { - this.livenessWatchInterval = setInterval(this.checkLiveness.bind(this), expiryInMs); + this.livenessWatchInterval = setInterval( + () => { this.monitorLiveness(); }, + expiryInMs, + ); } } } + /** + * Process Beacon locations + * Emits BeaconEvent.LocationUpdate + */ + public addLocations(beaconLocationEvents: MatrixEvent[]): void { + // discard locations for beacons that are not live + if (!this.isLive) { + return; + } + + const validLocationEvents = beaconLocationEvents.filter(event => { + const content = event.getContent(); + const timestamp = M_TIMESTAMP.findIn(content); + return ( + // only include positions that were taken inside the beacon's live period + isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) && + // ignore positions older than our current latest location + (!this.latestLocationState || timestamp > this.latestLocationState.timestamp) + ); + }); + const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0]; + + if (latestLocationEvent) { + this._latestLocationState = parseBeaconContent(latestLocationEvent.getContent()); + this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); + } + } + + private clearLatestLocation = () => { + this._latestLocationState = undefined; + this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); + }; + private setBeaconInfo(event: MatrixEvent): void { this._beaconInfo = parseBeaconInfoContent(event.getContent()); this.checkLiveness(); diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 043d5fa5567..5719034397c 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -407,6 +407,37 @@ export class RoomState extends TypedEventEmitter this.emit(RoomStateEvent.Update, this); } + public processBeaconEvents(events: MatrixEvent[]): void { + if ( + !events.length || + // discard locations if we have no beacons + !this.beacons.size + ) { + return; + } + + // names are confusing here + // a Beacon is the parent event, but event type is 'm.beacon_info' + // a location is the 'child' related to the Beacon, but the event type is 'm.beacon' + // group locations by beaconInfo event id + const locationEventsByBeaconEventId = events.reduce>((acc, event) => { + const beaconInfoEventId = event.getRelation()?.event_id; + if (!acc[beaconInfoEventId]) { + acc[beaconInfoEventId] = []; + } + acc[beaconInfoEventId].push(event); + return acc; + }, {}); + + Object.entries(locationEventsByBeaconEventId).forEach(([beaconInfoEventId, events]) => { + const beacon = [...this.beacons.values()].find(beacon => beacon.beaconInfoId === beaconInfoEventId); + + if (beacon) { + beacon.addLocations(events); + } + }); + } + /** * Looks up a member by the given userId, and if it doesn't exist, * create it and emit the `RoomState.newMember` event. @@ -441,6 +472,7 @@ export class RoomState extends TypedEventEmitter */ private setBeacon(event: MatrixEvent): void { const beaconIdentifier = getBeaconInfoIdentifier(event); + if (this.beacons.has(beaconIdentifier)) { const beacon = this.beacons.get(beaconIdentifier); @@ -470,6 +502,7 @@ export class RoomState extends TypedEventEmitter this.emit(BeaconEvent.New, event, beacon); beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); + this.beacons.set(beacon.identifier, beacon); } diff --git a/src/sync.ts b/src/sync.ts index c6e36e0f9b6..30df2cb7e6c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1636,6 +1636,7 @@ export class SyncApi { // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. room.addLiveEvents(timelineEventList || [], null, fromCache); + this.client.processBeaconEvents(room, timelineEventList); } /** diff --git a/src/utils.ts b/src/utils.ts index e17607808d0..dd1e4cfd53e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,6 +24,8 @@ import unhomoglyph from "unhomoglyph"; import promiseRetry from "p-retry"; import type NodeCrypto from "crypto"; +import { MatrixEvent } from "."; +import { M_TIMESTAMP } from "./@types/location"; /** * Encode a dictionary of query parameters. @@ -708,3 +710,15 @@ export function recursivelyAssign(target: Object, source: Object, ignoreNullish } return target; } + +function getContentTimestampWithFallback(event: MatrixEvent): number { + return M_TIMESTAMP.findIn(event.getContent()) ?? -1; +} + +/** + * Sort events by their content m.ts property + * Latest timestamp first + */ +export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number { + return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left); +} From 378802a5ab1a50ea9c71a9fc9e956bd52773c1d0 Mon Sep 17 00:00:00 2001 From: Callum Brown Date: Sat, 9 Apr 2022 05:53:57 +0100 Subject: [PATCH 06/22] Stabilise token authenticated registration support (#2181) * Stabilise token authenticated registration support Token authenticated registration was added to the Matrix specification in v1.2: https://spec.matrix.org/v1.2/client-server-api/#token-authenticated-registration Signed-off-by: Callum Brown * Backwards compatibility with unstable auth type Servers are not yet widely updated with support for the stable version of the registration token UIA type. Clients should check if the authentication type is either `RegistrationToken` or `UnstableRegistrationToken`. Signed-off-by: Callum Brown --- src/interactive-auth.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/interactive-auth.ts b/src/interactive-auth.ts index e55aa1d2ccb..f06369fe736 100644 --- a/src/interactive-auth.ts +++ b/src/interactive-auth.ts @@ -60,7 +60,11 @@ export enum AuthType { Sso = "m.login.sso", SsoUnstable = "org.matrix.login.sso", Dummy = "m.login.dummy", - RegistrationToken = "org.matrix.msc3231.login.registration_token", + RegistrationToken = "m.login.registration_token", + // For backwards compatability with servers that have not yet updated to + // use the stable "m.login.registration_token" type. + // The authentication flow is the same in both cases. + UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token", } export interface IAuthDict { @@ -79,7 +83,8 @@ export interface IAuthDict { // eslint-disable-next-line camelcase threepid_creds?: any; threepidCreds?: any; - registrationToken?: string; + // For m.login.registration_token type + token?: string; } class NoAuthFlowFoundError extends Error { From c6c22e394b99b8539250dbc8e2f8f146815482f2 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Sat, 9 Apr 2022 00:52:05 -0500 Subject: [PATCH 07/22] Ignore `eslint --fix` formatting changes in git blame (#2287) * Ignore eslint --fix formatting changes in git blame Docs: https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view * Conform on white-space spelling --- .git-blame-ignore-revs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..df3a62ea757 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,41 @@ +# Minor white-space adjustments +1d1d59c75744e1f6a2be1cb3e0d1bd9ded5f8025 +# Import ordering and spacing: eslint-plugin-import +80aaa6c32b50601f82e0c991c24e5a4590f39463 +# Minor white-space adjustment +8fb036ba2d01fab66dc4373802ccf19b5cac8541 +# Minor white-space adjustment +b63de6a902a9e1f8ffd7697dea33820fc04f028e +3ca84cfc491b0987eec1f13f13cae58d2032bf54 +# Conform to new typescript eslint rules +a87858840b57514603f63e2abbbda4f107f05a77 +5cf6684129a921295f5593173f16f192336fe0a2 +# Comply with new member-delimiter-style rule +b2ad957d298720d3e026b6bd91be0c403338361a +# Fix semicolons in TS files +e2ec8952e38b8fea3f0ccaa09ecb42feeba0d923 +# Migrate to `eslint-plugin-matrix-org` +# and `babel/...` to `@babel/...` migration +09fac77ce0d9bcf6637088c29afab84084f0e739 +102704e91a70643bcc09721e14b0d909f0ef55c6 +# Eslint formatting +cec00cd303787fa9008b6c48826e75ed438036fa +# Minor eslint changes +68bb8182e4e62d8f450f80c408c4b231b8725f1b +c979ff6696e30ab8983ac416a3590996d84d3560 +f4a7395e3a3751a1a8e92dd302c49175a3296ad2 +# eslint --fix for dangley commas on function calls +423175f5397910b0afe3112d6fb18283fc7d27d4 +# eslint ---fix for prefer-const +7bca05af644e8b997dae81e568a3913d8f18d7ca +# Fix linting on tests +cee7f7a280a8c20bafc21c0a2911f60851f7a7ca +# eslint --fix +0fa9f7c6098822db1ae214f352fd1fe5c248b02c +# eslint --fix for lots of white-space +5abf6b9f208801c5022a47023150b5846cb0b309 +# eslint --fix +7ed65407e6cdf292ce3cf659310c68d19dcd52b2 +# Switch to ESLint from JSHint (Google eslint rules as a base) +e057956ede9ad1a931ff8050c411aca7907e0394 + From 5937e6a6a80780f2947366bd9c4266abbc799259 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sat, 9 Apr 2022 14:07:33 +0100 Subject: [PATCH 08/22] Support for MSC2457 logout_devices param for setPassword() (#2285) --- spec/unit/matrix-client.spec.ts | 53 +++++++++++++++++++++++++++++++++ src/client.ts | 31 +++++++++++++++++-- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index cb782330a88..b8659bd9b4e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -1056,4 +1056,57 @@ describe("MatrixClient", function() { }); }); }); + + describe("setPassword", () => { + const auth = { session: 'abcdef', type: 'foo' }; + const newPassword = 'newpassword'; + const callback = () => {}; + + const passwordTest = (expectedRequestContent: any, expectedCallback?: Function) => { + const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0]; + if (expectedCallback) { + expect(callback).toBe(expectedCallback); + } else { + expect(callback).toBeFalsy(); + } + expect(method).toBe('POST'); + expect(path).toEqual('/account/password'); + expect(queryParams).toBeFalsy(); + expect(requestContent).toEqual(expectedRequestContent); + }; + + beforeEach(() => { + client.http.authedRequest.mockClear().mockResolvedValue({}); + }); + + it("no logout_devices specified", async () => { + await client.setPassword(auth, newPassword); + passwordTest({ auth, new_password: newPassword }); + }); + + it("no logout_devices specified + callback", async () => { + await client.setPassword(auth, newPassword, callback); + passwordTest({ auth, new_password: newPassword }, callback); + }); + + it("overload logoutDevices=true", async () => { + await client.setPassword(auth, newPassword, true); + passwordTest({ auth, new_password: newPassword, logout_devices: true }); + }); + + it("overload logoutDevices=true + callback", async () => { + await client.setPassword(auth, newPassword, true, callback); + passwordTest({ auth, new_password: newPassword, logout_devices: true }, callback); + }); + + it("overload logoutDevices=false", async () => { + await client.setPassword(auth, newPassword, false); + passwordTest({ auth, new_password: newPassword, logout_devices: false }); + }); + + it("overload logoutDevices=false + callback", async () => { + await client.setPassword(auth, newPassword, false, callback); + passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback); + }); + }); }); diff --git a/src/client.ts b/src/client.ts index 7ec65d27db6..60d3f370430 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7819,18 +7819,45 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public setPassword( + authDict: any, + newPassword: string, + callback?: Callback, + ): Promise<{}>; + public setPassword( + authDict: any, + newPassword: string, + logoutDevices: boolean, + callback?: Callback, + ): Promise<{}>; + public setPassword( + authDict: any, + newPassword: string, + logoutDevices?: Callback | boolean, + callback?: Callback, + ): Promise<{}> { + if (typeof logoutDevices === 'function') { + callback = logoutDevices; + } + if (typeof logoutDevices !== 'boolean') { + // Use backwards compatible behaviour of not specifying logout_devices + // This way it is left up to the server: + logoutDevices = undefined; + } + const path = "/account/password"; const data = { 'auth': authDict, 'new_password': newPassword, + 'logout_devices': logoutDevices, }; - return this.http.authedRequest( + return this.http.authedRequest<{}>( callback, Method.Post, path, null, data, ); } From 286500e335e45673622a892aeb3e550f1b6a2e3d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 11 Apr 2022 08:58:13 +0100 Subject: [PATCH 09/22] Fix issues around echo & redaction handling in threads (#2286) --- spec/test-utils/test-utils.ts | 17 ++++- spec/unit/room.spec.ts | 136 +++++++++++++++++++++++++++++++--- src/event-mapper.ts | 3 + src/models/room.ts | 16 ++-- src/models/thread.ts | 32 ++++++-- 5 files changed, 175 insertions(+), 29 deletions(-) diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index ab6b7d710fc..5b4fb985063 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -8,6 +8,7 @@ import { logger } from '../../src/logger'; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { ClientEvent, EventType, MatrixClient } from "../../src"; import { SyncState } from "../../src/sync"; +import { eventMapperFor } from "../../src/event-mapper"; /** * Return a promise that is resolved when the client next emits a @@ -79,6 +80,7 @@ interface IEventOpts { redacts?: string; } +let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events /** * Create an Event. * @param {Object} opts Values for the event. @@ -88,9 +90,10 @@ interface IEventOpts { * @param {string} opts.skey Optional. The state key (auto inserts empty string) * @param {Object} opts.content The event.content * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object} a JSON object representing this event. */ -export function mkEvent(opts: IEventOpts): object | MatrixEvent { +export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent { if (!opts.type || !opts.content) { throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); } @@ -100,7 +103,7 @@ export function mkEvent(opts: IEventOpts): object | MatrixEvent { sender: opts.sender || opts.user, // opts.user for backwards-compat content: opts.content, unsigned: opts.unsigned || {}, - event_id: "$" + Math.random() + "-" + Math.random(), + event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(), txn_id: "~" + Math.random(), redacts: opts.redacts, }; @@ -117,6 +120,11 @@ export function mkEvent(opts: IEventOpts): object | MatrixEvent { ].includes(opts.type)) { event.state_key = ""; } + + if (opts.event && client) { + return eventMapperFor(client, {})(event); + } + return opts.event ? new MatrixEvent(event) : event; } @@ -209,9 +217,10 @@ interface IMessageOpts { * @param {string} opts.user The user ID for the event. * @param {string} opts.msg Optional. The content.body for the event. * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object|MatrixEvent} The event */ -export function mkMessage(opts: IMessageOpts): object | MatrixEvent { +export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMessage, @@ -224,7 +233,7 @@ export function mkMessage(opts: IMessageOpts): object | MatrixEvent { if (!eventOpts.content.body) { eventOpts.content.body = "Random->" + Math.random(); } - return mkEvent(eventOpts); + return mkEvent(eventOpts, client); } /** diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 8cf41b5f5c4..85f4c21d572 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -50,7 +50,7 @@ describe("Room", function() { event: true, user: userA, room: roomId, - }) as MatrixEvent; + }, room.client) as MatrixEvent; const mkReply = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -65,7 +65,7 @@ describe("Room", function() { }, }, }, - }) as MatrixEvent; + }, room.client) as MatrixEvent; const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ event: true, @@ -82,7 +82,7 @@ describe("Room", function() { event_id: target.getId(), }, }, - }) as MatrixEvent; + }, room.client) as MatrixEvent; const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ event: true, @@ -99,7 +99,7 @@ describe("Room", function() { "rel_type": "m.thread", }, }, - }) as MatrixEvent; + }, room.client) as MatrixEvent; const mkReaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -113,7 +113,7 @@ describe("Room", function() { "key": Math.random().toString(), }, }, - }) as MatrixEvent; + }, room.client) as MatrixEvent; const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -122,7 +122,7 @@ describe("Room", function() { room: roomId, redacts: target.getId(), content: {}, - }) as MatrixEvent; + }, room.client) as MatrixEvent; beforeEach(function() { room = new Room(roomId, new TestClient(userA, "device").client, userA); @@ -1899,6 +1899,7 @@ describe("Room", function() { "@alice:example.com", "alicedevice", )).client; room = new Room(roomId, client, userA); + client.getRoom = () => room; }); it("allow create threads without a root event", function() { @@ -1938,11 +1939,7 @@ describe("Room", function() { }); it("Edits update the lastReply event", async () => { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - client.supportsExperimentalThreads = () => true; - room = new Room(roomId, client, userA); + room.client.supportsExperimentalThreads = () => true; const randomMessage = mkMessage(); const threadRoot = mkMessage(); @@ -1951,7 +1948,7 @@ describe("Room", function() { const threadResponseEdit = mkEdit(threadResponse); threadResponseEdit.localTimestamp += 2000; - client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ ...threadRoot.event, unsigned: { "age": 123, @@ -1975,6 +1972,121 @@ describe("Room", function() { await emitPromise(thread, ThreadEvent.Update); expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); + + it("Redactions to thread responses decrement the length", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse1Redaction = mkRedaction(threadResponse1); + room.addLiveEvents([threadResponse1Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(1); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + }); + + it("Redactions to reactions in threads do not decrement the length", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + const threadResponse2Reaction = mkReaction(threadResponse2); + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); + room.addLiveEvents([threadResponse2ReactionRedaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + }); + + it("Redacting the lastEvent finds a new lastEvent", async () => { + room.client.supportsExperimentalThreads = () => true; + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.localTimestamp += 1000; + const threadResponse2 = mkThreadResponse(threadRoot); + threadResponse2.localTimestamp += 2000; + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse2.event, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + + room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + const thread = await emitPromise(room, ThreadEvent.New); + + expect(thread).toHaveLength(2); + expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + + const threadResponse2Redaction = mkRedaction(threadResponse2); + room.addLiveEvents([threadResponse2Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(1); + expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); + + const threadResponse1Redaction = mkRedaction(threadResponse1); + room.addLiveEvents([threadResponse1Redaction]); + await emitPromise(thread, ThreadEvent.Update); + expect(thread).toHaveLength(0); + expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); + }); }); describe("eventShouldLiveIn", () => { diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 5349a565c83..92b2683046c 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -66,6 +66,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange, ]); + room?.reEmitter.reEmit(event, [ + MatrixEventEvent.BeforeRedaction, + ]); } return event; } diff --git a/src/models/room.ts b/src/models/room.ts index 35945e71fce..af3dd4758b8 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -23,7 +23,7 @@ import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; import { defer, normalize } from "../utils"; -import { IEvent, IThreadBundledRelationship, MatrixEvent } from "./event"; +import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event"; import { EventStatus } from "./event-status"; import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; @@ -171,7 +171,8 @@ type EmittedEvents = RoomEvent | ThreadEvent.Update | ThreadEvent.NewReply | RoomEvent.Timeline - | RoomEvent.TimelineReset; + | RoomEvent.TimelineReset + | MatrixEventEvent.BeforeRedaction; export type RoomEventHandlerMap = { [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; @@ -188,10 +189,10 @@ export type RoomEventHandlerMap = { oldStatus?: EventStatus, ) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; -} & ThreadHandlerMap; +} & ThreadHandlerMap & MatrixEventHandlerMap; export class Room extends TypedEventEmitter { - private readonly reEmitter: TypedReEmitter; + public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } // receipts should clobber based on receipt_type and user_id pairs hence // the form of this structure. This is sub-optimal for the exposed APIs @@ -383,7 +384,7 @@ export class Room extends TypedEventEmitter return this.threadTimelineSetsPromise; } - if (this.client?.supportsExperimentalThreads) { + if (this.client?.supportsExperimentalThreads()) { try { this.threadTimelineSetsPromise = Promise.all([ this.createThreadTimelineSet(), @@ -1676,6 +1677,8 @@ export class Room extends TypedEventEmitter thread = await this.threadPromises.get(threadId); } + events = events.filter(e => e.getId() !== threadId); // filter out any root events + if (thread) { for (const event of events) { await thread.addEvent(event, toStartOfTimeline); @@ -1810,7 +1813,7 @@ export class Room extends TypedEventEmitter } }; - private processLiveEvent(event: MatrixEvent): Promise { + private processLiveEvent(event: MatrixEvent): void { this.applyRedaction(event); // Implement MSC3531: hiding messages. @@ -1827,7 +1830,6 @@ export class Room extends TypedEventEmitter if (existingEvent) { // remote echo of an event we sent earlier this.handleRemoteEcho(event, existingEvent); - return; } } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 3b13c1be87b..549336b4f31 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, RelationType, RoomEvent } from "../matrix"; +import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; import { IRelationsRequestOpts } from "../@types/requests"; import { IThreadBundledRelationship, MatrixEvent } from "./event"; @@ -94,6 +94,7 @@ export class Thread extends TypedEventEmitter { RoomEvent.TimelineReset, ]); + this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); this.timelineSet.on(RoomEvent.Timeline, this.onEcho); @@ -114,7 +115,29 @@ export class Thread extends TypedEventEmitter { } } + private onBeforeRedaction = (event: MatrixEvent) => { + if (event?.isRelation(THREAD_RELATION_TYPE.name) && + this.room.eventShouldLiveIn(event).threadId === this.id + ) { + this.replyCount--; + this.emit(ThreadEvent.Update, this); + } + + if (this.lastEvent?.getId() === event.getId()) { + const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); + this.lastEvent = events.find(e => ( + !e.isRedacted() && + e.getId() !== event.getId() && + e.isRelation(THREAD_RELATION_TYPE.name) + )) ?? this.rootEvent; + this.emit(ThreadEvent.NewReply, this, this.lastEvent); + } + }; + private onEcho = (event: MatrixEvent) => { + if (event.threadRootId !== this.id) return; // ignore echoes for other timelines + if (this.lastEvent === event) return; + // There is a risk that the `localTimestamp` approximation will not be accurate // when threads are used over federation. That could result in the reply // count value drifting away from the value returned by the server @@ -135,9 +158,7 @@ export class Thread extends TypedEventEmitter { } } - if (this.timelineSet.eventIdToTimeline(event.getId())) { - this.emit(ThreadEvent.Update, this); - } + this.emit(ThreadEvent.Update, this); }; public get roomState(): RoomState { @@ -188,10 +209,9 @@ export class Thread extends TypedEventEmitter { this._currentUserParticipated = true; } - const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); // If no thread support exists we want to count all thread relation // added as a reply. We can't rely on the bundled relationships count - if (!Thread.hasServerSideSupport && isThreadReply) { + if (!Thread.hasServerSideSupport && event.isRelation(THREAD_RELATION_TYPE.name)) { this.replyCount++; } From b8ec62e7866f2a7192e31c911caa21421669962a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 16:31:21 +0100 Subject: [PATCH 10/22] Merge master --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ package.json | 7 ++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0844fd97ed6..93b09b018ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +Changes in [17.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0) (2022-04-11) +================================================================================================== + +## 🚨 BREAKING CHANGES + * Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)). + +## ✨ Features + * Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)). + * Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)). + +## 🐛 Bug Fixes + * Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy. + * Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543. + * Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)). + +Changes in [16.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.2-rc.1) (2022-04-05) +============================================================================================================ + +## 🚨 BREAKING CHANGES + * Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)). + +## ✨ Features + * Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)). + * Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)). + +## 🐛 Bug Fixes + * Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy. + * Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543. + * Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)). + Changes in [16.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.1) (2022-03-28) ================================================================================================== @@ -1988,6 +2018,12 @@ All Changes * [BREAKING] Refactor the entire build process [\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113) +Changes in [3.42.2-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.42.2-rc.3) (2022-04-08) +============================================================================================================ + +## 🐛 Bug Fixes + * Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661. + Changes in [3.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0) (2020-01-13) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0-rc.1...v3.0.0) diff --git a/package.json b/package.json index 2664aab382e..999911f7ef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "16.0.1", + "version": "17.0.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -29,7 +29,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -116,5 +116,6 @@ "text", "json" ] - } + }, + "typings": "./lib/index.d.ts" } From 3b33237e51ae549614da29dd93de9cf874f079b1 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 11 Apr 2022 16:31:44 +0100 Subject: [PATCH 11/22] Resetting package fields for development --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 999911f7ef0..8d86fd650d2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", + "main": "./src/index.ts", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -116,6 +116,5 @@ "text", "json" ] - }, - "typings": "./lib/index.d.ts" + } } From b58d09aa9a7a3578c43185becb41ab0b17ce0f98 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Apr 2022 09:42:55 +0100 Subject: [PATCH 12/22] Prevent duplicated re-emitter setups in event-mapper (#2293) --- spec/unit/matrix-client.spec.ts | 3 +++ src/client.ts | 9 +++++++++ src/event-mapper.ts | 4 +++- src/models/thread.ts | 26 ++++++++++++++------------ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index b8659bd9b4e..680e14ed5eb 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -816,6 +816,9 @@ describe("MatrixClient", function() { }, addPendingEvent: jest.fn(), updatePendingEvent: jest.fn(), + reEmitter: { + reEmit: jest.fn(), + }, }; beforeEach(() => { diff --git a/src/client.ts b/src/client.ts index 60d3f370430..154e947b548 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3779,6 +3779,15 @@ export class MatrixClient extends TypedEventEmitter) { @@ -43,6 +43,8 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event } else { // merge the latest unsigned data from the server event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned }); + // prevent doubling up re-emitters + preventReEmit = true; } const thread = room?.findThreadForEvent(event); diff --git a/src/models/thread.ts b/src/models/thread.ts index 549336b4f31..14a036b3046 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -95,6 +95,7 @@ export class Thread extends TypedEventEmitter { ]); this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.room.on(RoomEvent.Redaction, this.onRedaction); this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); this.timelineSet.on(RoomEvent.Timeline, this.onEcho); @@ -115,23 +116,24 @@ export class Thread extends TypedEventEmitter { } } - private onBeforeRedaction = (event: MatrixEvent) => { + private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { if (event?.isRelation(THREAD_RELATION_TYPE.name) && - this.room.eventShouldLiveIn(event).threadId === this.id + this.room.eventShouldLiveIn(event).threadId === this.id && + !redaction.status // only respect it when it succeeds ) { this.replyCount--; this.emit(ThreadEvent.Update, this); } + }; - if (this.lastEvent?.getId() === event.getId()) { - const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); - this.lastEvent = events.find(e => ( - !e.isRedacted() && - e.getId() !== event.getId() && - e.isRelation(THREAD_RELATION_TYPE.name) - )) ?? this.rootEvent; - this.emit(ThreadEvent.NewReply, this, this.lastEvent); - } + private onRedaction = (event: MatrixEvent) => { + if (event.threadRootId !== this.id) return; // ignore redactions for other timelines + const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); + this.lastEvent = events.find(e => ( + !e.isRedacted() && + e.isRelation(THREAD_RELATION_TYPE.name) + )) ?? this.rootEvent; + this.emit(ThreadEvent.Update, this); }; private onEcho = (event: MatrixEvent) => { @@ -142,7 +144,7 @@ export class Thread extends TypedEventEmitter { // when threads are used over federation. That could result in the reply // count value drifting away from the value returned by the server const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); - if (!this.lastEvent || (isThreadReply + if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply && (event.getId() !== this.lastEvent.getId()) && (event.localTimestamp > this.lastEvent.localTimestamp)) ) { From 54661cca955bd6dcfe65230167d821a3e9f0fc89 Mon Sep 17 00:00:00 2001 From: Jonathan de Jong Date: Wed, 13 Apr 2022 01:48:48 +0200 Subject: [PATCH 13/22] Change `_unstable_getSharedRooms` to `_unstable_getMutualRooms` (#2271) * change shared to mutual * revert name change * use new unstable feature flag * support both unstable endpoints --- src/client.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 154e947b548..bc55145e557 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6410,12 +6410,18 @@ export class MatrixClient extends TypedEventEmitter { // eslint-disable-line - if (!(await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"))) { - throw Error('Server does not support shared_rooms API'); + const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"); + const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms"); + + if (!sharedRoomsSupport && !mutualRoomsSupport) { + throw Error('Server does not support mutual_rooms API'); } - const path = utils.encodeUri("/uk.half-shot.msc2666/user/shared_rooms/$userId", { - $userId: userId, - }); + + const path = utils.encodeUri( + `/uk.half-shot.msc2666/user/${mutualRoomsSupport ? 'mutual_rooms' : 'shared_rooms'}/$userId`, + { $userId: userId }, + ); + const res = await this.http.authedRequest<{ joined: string[] }>( undefined, Method.Get, path, undefined, undefined, { prefix: PREFIX_UNSTABLE }, From 9aab9178360b27d351ddc6eeb5771b9570ec3deb Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 12 Apr 2022 20:22:14 -0400 Subject: [PATCH 14/22] Fix coverage diffs for PRs that aren't up to date, take 3 (#2294) --- .github/workflows/test_coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index c0699cfe466..db1e46d1fff 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -29,3 +29,4 @@ jobs: with: fail_ci_if_error: false verbose: true + override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} From c3d7a4977afadb032867775120d0f571ee087b29 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 13 Apr 2022 16:23:15 +0100 Subject: [PATCH 15/22] Stop tracking threads if threads support is disabled (#2295) --- spec/integ/matrix-client-event-timeline.spec.js | 1 + src/client.ts | 6 ++++-- src/models/room.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index ad475f3fc5a..6e93a063a8d 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -496,6 +496,7 @@ describe("MatrixClient event timelines", function() { }); it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { + client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(true); client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId); diff --git a/src/client.ts b/src/client.ts index bc55145e557..a601c96de24 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3776,7 +3776,6 @@ export class MatrixClient extends TypedEventEmitter public threadsReady = false; public async fetchRoomThreads(): Promise { - if (this.threadsReady) { + if (this.threadsReady || !this.client.supportsExperimentalThreads()) { return; } @@ -1662,7 +1662,7 @@ export class Room extends TypedEventEmitter // benefit from all the APIs a homeserver can provide to enhance the thread experience thread = this.createThread(rootEvent, events, toStartOfTimeline); if (thread) { - rootEvent.setThread(thread); + rootEvent?.setThread(thread); } deferred.resolve(thread); } From fbe81ad823e1384040902cd02dd822c46a906014 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 14 Apr 2022 15:04:43 +0200 Subject: [PATCH 16/22] Live location sharing - expose room liveBeaconIds (#2296) * updates rooms live beacon ids on destroy Signed-off-by: Kerry Archibald * expose live beacons ids Signed-off-by: Kerry Archibald * room state emit all the time on beacon liveness change Signed-off-by: Kerry Archibald * update comment Signed-off-by: Kerry Archibald --- spec/unit/room-state.spec.js | 2 +- src/models/room-state.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 261f7572d91..c91bff5bd8e 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -334,7 +334,7 @@ describe("RoomState", function() { state.setStateEvents([updatedLiveBeaconEvent]); expect(state.hasLiveBeacons).toBe(false); - expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2); + expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(3); expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false); }); }); diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 5719034397c..ba127920772 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -84,7 +84,7 @@ export class RoomState extends TypedEventEmitter public paginationToken: string = null; public readonly beacons = new Map(); - private liveBeaconIds: BeaconIdentifier[] = []; + private _liveBeaconIds: BeaconIdentifier[] = []; /** * Construct room state. @@ -251,6 +251,10 @@ export class RoomState extends TypedEventEmitter return !!this.liveBeaconIds?.length; } + public get liveBeaconIds(): BeaconIdentifier[] { + return this._liveBeaconIds; + } + /** * Creates a copy of this room state so that mutations to either won't affect the other. * @return {RoomState} the copy of the room state @@ -502,6 +506,7 @@ export class RoomState extends TypedEventEmitter this.emit(BeaconEvent.New, event, beacon); beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); + beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this)); this.beacons.set(beacon.identifier, beacon); } @@ -509,19 +514,14 @@ export class RoomState extends TypedEventEmitter /** * @experimental * Check liveness of room beacons - * emit RoomStateEvent.BeaconLiveness when - * roomstate.hasLiveBeacons has changed + * emit RoomStateEvent.BeaconLiveness event */ private onBeaconLivenessChange(): void { - const prevHasLiveBeacons = !!this.liveBeaconIds?.length; - this.liveBeaconIds = Array.from(this.beacons.values()) + this._liveBeaconIds = Array.from(this.beacons.values()) .filter(beacon => beacon.isLive) .map(beacon => beacon.identifier); - const hasLiveBeacons = !!this.liveBeaconIds.length; - if (prevHasLiveBeacons !== hasLiveBeacons) { - this.emit(RoomStateEvent.BeaconLiveness, this, hasLiveBeacons); - } + this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons); } private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { From cde935629d6598f5cc9763cfd1f5dfff87c06975 Mon Sep 17 00:00:00 2001 From: CommanderRoot Date: Thu, 14 Apr 2022 23:23:27 +0200 Subject: [PATCH 17/22] Replace deprecated String.prototype.substr() (#2298) .substr() is deprecated so we replace it with .slice() which works similarily but isn't deprecated Signed-off-by: Tobias Speicher --- examples/node/app.js | 4 ++-- src/content-repo.ts | 4 ++-- src/crypto/store/localStorage-crypto-store.ts | 10 +++++----- src/crypto/store/memory-crypto-store.ts | 8 ++++---- src/filter-component.ts | 2 +- src/store/session/webstorage.js | 10 +++++----- src/utils.ts | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/node/app.js b/examples/node/app.js index 90396b56bbd..ae7eb707690 100644 --- a/examples/node/app.js +++ b/examples/node/app.js @@ -341,7 +341,7 @@ function printLine(event) { var maxNameWidth = 15; if (name.length > maxNameWidth) { - name = name.substr(0, maxNameWidth-1) + "\u2026"; + name = name.slice(0, maxNameWidth-1) + "\u2026"; } if (event.getType() === "m.room.message") { @@ -398,7 +398,7 @@ function print(str, formatter) { function fixWidth(str, len) { if (str.length > len) { - return str.substr(0, len-2) + "\u2026"; + return str.substring(0, len-2) + "\u2026"; } else if (str.length < len) { return str + new Array(len - str.length).join(" "); diff --git a/src/content-repo.ts b/src/content-repo.ts index febd0d1c960..333126a008d 100644 --- a/src/content-repo.ts +++ b/src/content-repo.ts @@ -73,8 +73,8 @@ export function getHttpUriForMxc( const fragmentOffset = serverAndMediaId.indexOf("#"); let fragment = ""; if (fragmentOffset >= 0) { - fragment = serverAndMediaId.substr(fragmentOffset); - serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); + fragment = serverAndMediaId.slice(fragmentOffset); + serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset); } const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); diff --git a/src/crypto/store/localStorage-crypto-store.ts b/src/crypto/store/localStorage-crypto-store.ts index 599e74cb9b2..e9e0f99ca64 100644 --- a/src/crypto/store/localStorage-crypto-store.ts +++ b/src/crypto/store/localStorage-crypto-store.ts @@ -228,8 +228,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { // (hence 43 characters long). func({ - senderKey: key.substr(KEY_INBOUND_SESSION_PREFIX.length, 43), - sessionId: key.substr(KEY_INBOUND_SESSION_PREFIX.length + 44), + senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43), + sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44), sessionData: getJsonItem(this.store, key), }); } @@ -299,7 +299,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { for (let i = 0; i < this.store.length; ++i) { const key = this.store.key(i); if (key.startsWith(prefix)) { - const roomId = key.substr(prefix.length); + const roomId = key.slice(prefix.length); result[roomId] = getJsonItem(this.store, key); } } @@ -313,8 +313,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore { for (const session in sessionsNeedingBackup) { if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { // see getAllEndToEndInboundGroupSessions for the magic number explanations - const senderKey = session.substr(0, 43); - const sessionId = session.substr(44); + const senderKey = session.slice(0, 43); + const sessionId = session.slice(44); this.getEndToEndInboundGroupSession( senderKey, sessionId, null, (sessionData) => { diff --git a/src/crypto/store/memory-crypto-store.ts b/src/crypto/store/memory-crypto-store.ts index 31dedd9ec9f..2e441908e3c 100644 --- a/src/crypto/store/memory-crypto-store.ts +++ b/src/crypto/store/memory-crypto-store.ts @@ -418,8 +418,8 @@ export class MemoryCryptoStore implements CryptoStore { // (hence 43 characters long). func({ - senderKey: key.substr(0, 43), - sessionId: key.substr(44), + senderKey: key.slice(0, 43), + sessionId: key.slice(44), sessionData: this.inboundGroupSessions[key], }); } @@ -482,8 +482,8 @@ export class MemoryCryptoStore implements CryptoStore { for (const session in this.sessionsNeedingBackup) { if (this.inboundGroupSessions[session]) { sessions.push({ - senderKey: session.substr(0, 43), - sessionId: session.substr(44), + senderKey: session.slice(0, 43), + sessionId: session.slice(44), sessionData: this.inboundGroupSessions[session], }); if (limit && session.length >= limit) { diff --git a/src/filter-component.ts b/src/filter-component.ts index 18a6b53b5b6..8cfbea667f3 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -36,7 +36,7 @@ import { function matchesWildcard(actualValue: string, filterValue: string): boolean { if (filterValue.endsWith("*")) { const typePrefix = filterValue.slice(0, -1); - return actualValue.substr(0, typePrefix.length) === typePrefix; + return actualValue.slice(0, typePrefix.length) === typePrefix; } else { return actualValue === filterValue; } diff --git a/src/store/session/webstorage.js b/src/store/session/webstorage.js index 91d5f31e5dd..f11bbe20798 100644 --- a/src/store/session/webstorage.js +++ b/src/store/session/webstorage.js @@ -78,7 +78,7 @@ WebStorageSessionStore.prototype = { const devices = {}; for (let i = 0; i < this.store.length; ++i) { const key = this.store.key(i); - const userId = key.substr(prefix.length); + const userId = key.slice(prefix.length); if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key); } return devices; @@ -125,7 +125,7 @@ WebStorageSessionStore.prototype = { const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions('')); const results = {}; for (const k of deviceKeys) { - const unprefixedKey = k.substr(keyEndToEndSessions('').length); + const unprefixedKey = k.slice(keyEndToEndSessions('').length); results[unprefixedKey] = getJsonItem(this.store, k); } return results; @@ -158,8 +158,8 @@ WebStorageSessionStore.prototype = { // (hence 43 characters long). result.push({ - senderKey: key.substr(prefix.length, 43), - sessionId: key.substr(prefix.length + 44), + senderKey: key.slice(prefix.length, prefix.length + 43), + sessionId: key.slice(prefix.length + 44), }); } return result; @@ -182,7 +182,7 @@ WebStorageSessionStore.prototype = { const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom('')); const results = {}; for (const k of roomKeys) { - const unprefixedKey = k.substr(keyEndToEndRoom('').length); + const unprefixedKey = k.slice(keyEndToEndRoom('').length); results[unprefixedKey] = getJsonItem(this.store, k); } return results; diff --git a/src/utils.ts b/src/utils.ts index dd1e4cfd53e..80929c36e5a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -427,7 +427,7 @@ export function globToRegexp(glob: string, extended?: any): string { export function ensureNoTrailingSlash(url: string): string { if (url && url.endsWith("/")) { - return url.substr(0, url.length - 1); + return url.slice(0, -1); } else { return url; } From 9f4598638d56b77182d10764292ec50b73082923 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 15 Apr 2022 10:27:12 +0100 Subject: [PATCH 18/22] Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 (#2297) --- src/client.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/client.ts b/src/client.ts index a601c96de24..f98861da227 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6593,6 +6593,14 @@ export class MatrixClient extends TypedEventEmitter} true if server supports the `logout_devices` parameter + */ + public doesServerSupportLogoutDevices(): Promise { + return this.isVersionSupported("r0.6.1"); + } + /** * Get if lazy loading members is being used. * @return {boolean} Whether or not members are lazy loaded by this client From 48d3fce22dc1030050c0f6159b83f285c57cdc8b Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Apr 2022 14:41:26 +0100 Subject: [PATCH 19/22] Prepare changelog for v17.1.0-rc.1 --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b09b018ea..2a8f452c74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +Changes in [17.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.1.0-rc.1) (2022-04-19) +============================================================================================================ + +## ✨ Features + * Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 ([\#2297](https://github.com/matrix-org/matrix-js-sdk/pull/2297)). + * Live location sharing - expose room liveBeaconIds ([\#2296](https://github.com/matrix-org/matrix-js-sdk/pull/2296)). + * Support for MSC2457 logout_devices param for setPassword() ([\#2285](https://github.com/matrix-org/matrix-js-sdk/pull/2285)). + * Stabilise token authenticated registration support ([\#2181](https://github.com/matrix-org/matrix-js-sdk/pull/2181)). Contributed by @govynnus. + * Live location sharing - Aggregate beacon locations on beacons ([\#2268](https://github.com/matrix-org/matrix-js-sdk/pull/2268)). + +## 🐛 Bug Fixes + * Prevent duplicated re-emitter setups in event-mapper ([\#2293](https://github.com/matrix-org/matrix-js-sdk/pull/2293)). + * Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661. + Changes in [17.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0) (2022-04-11) ================================================================================================== From 91a67bdac30533f0d66ad71d78765b4af7acca44 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Apr 2022 14:41:27 +0100 Subject: [PATCH 20/22] v17.1.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8d86fd650d2..c84b12b7b11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.0.0", + "version": "17.1.0-rc.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -29,7 +29,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -116,5 +116,6 @@ "text", "json" ] - } + }, + "typings": "./lib/index.d.ts" } From 2d9b4b38961f6efd127042ee1d13ab16f28fe973 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 26 Apr 2022 11:13:46 +0100 Subject: [PATCH 21/22] Prepare changelog for v17.1.0 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8f452c74c..04f4fe74979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ -Changes in [17.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.1.0-rc.1) (2022-04-19) -============================================================================================================ +Changes in [17.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.1.0) (2022-04-26) +================================================================================================== ## ✨ Features * Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 ([\#2297](https://github.com/matrix-org/matrix-js-sdk/pull/2297)). From 738876a563da7937a33144f00fce5142bc5bcf10 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 26 Apr 2022 11:13:47 +0100 Subject: [PATCH 22/22] v17.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c84b12b7b11..87efc51e2ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.1.0-rc.1", + "version": "17.1.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build",