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 + 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 || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b09b018ea..04f4fe74979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +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)). + * 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) ================================================================================================== 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/package.json b/package.json index 999911f7ef0..87efc51e2ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.0.0", + "version": "17.1.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", 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/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..680e14ed5eb 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(); @@ -815,6 +816,9 @@ describe("MatrixClient", function() { }, addPendingEvent: jest.fn(), updatePendingEvent: jest.fn(), + reEmitter: { + reEmit: jest.fn(), + }, }; beforeEach(() => { @@ -999,10 +1003,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,17 +1019,97 @@ 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); }); + + 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]); + }); + }); + }); + + 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/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 0e39e7c6961..dc4058d1ce4 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,15 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType } from "../../../src"; -import { M_BEACON_INFO } from "../../../src/@types/beacon"; import { isTimestampInDuration, - isBeaconInfoEventType, Beacon, BeaconEvent, } from "../../../src/models/beacon"; -import { makeBeaconInfoEvent } from "../../test-utils/beacon"; +import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon"; jest.useFakeTimers(); @@ -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); @@ -277,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 fa00d21fc9a..c91bff5bd8e 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,8 +1,8 @@ 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 } 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'); @@ -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); }); }); @@ -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/@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 4a460ec1e1b..f98861da227 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, M_BEACON_INFO } from "./@types/beacon"; export type Store = IStore; export type SessionStore = WebStorageSessionStore; @@ -3649,39 +3649,30 @@ 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 }, @@ -6581,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 @@ -7824,18 +7844,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, ); } @@ -8860,6 +8907,17 @@ 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 393cb2e54a2..383b9b34396 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -18,7 +18,7 @@ limitations under the License. import { REFERENCE_RELATION } from "matrix-events-sdk"; -import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; +import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MsgType } from "./@types/event"; import { TEXT_NODE_TYPE } from "./@types/extensible_events"; import { @@ -208,11 +208,9 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = ( assetType, timestamp, ) => ({ - [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,21 @@ export const makeBeaconContent: MakeBeaconContent = ( [M_TIMESTAMP.name]: timestamp, "m.relates_to": { rel_type: REFERENCE_RELATION.name, - event_id: beaconInfoId, + 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/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/event-mapper.ts b/src/event-mapper.ts index 92b2683046c..40ef5a824a9 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -25,7 +25,7 @@ export interface MapperOpts { } export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper { - const preventReEmit = Boolean(options.preventReEmit); + let preventReEmit = Boolean(options.preventReEmit); const decrypt = options.decrypt !== false; function mapper(plainOldJsObject: Partial) { @@ -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/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/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 { diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 329fc04e6bd..caea773275d 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -14,22 +14,27 @@ 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 { 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 = ( @@ -38,16 +43,19 @@ 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; private _isLive: boolean; private livenessWatchInterval: number; + private _latestLocationState: BeaconLocationState | undefined; constructor( private rootEvent: MatrixEvent, @@ -61,8 +69,8 @@ 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 bcba62abe2c..ba127920772 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -26,8 +26,11 @@ import { MatrixEvent } from "./event"; import { MatrixClient } from "../client"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; import { TypedEventEmitter } from "./typed-event-emitter"; -import { Beacon, BeaconEvent, isBeaconInfoEventType, BeaconEventHandlerMap } from "./beacon"; +import { Beacon, BeaconEvent, BeaconEventHandlerMap } from "./beacon"; import { TypedReEmitter } from "../ReEmitter"; +import { M_BEACON_INFO } from "../@types/beacon"; +import { getBeaconInfoIdentifier } from "./beacon"; +import { BeaconIdentifier } from ".."; // possible statuses for out-of-band member loading enum OobStatus { @@ -80,8 +83,8 @@ export class RoomState 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. @@ -248,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 @@ -330,7 +337,7 @@ export class RoomState extends TypedEventEmitter return; } - if (isBeaconInfoEventType(event.getType())) { + if (M_BEACON_INFO.matches(event.getType())) { this.setBeacon(event); } @@ -404,6 +411,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. @@ -437,12 +475,16 @@ 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,25 +506,22 @@ 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); + beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this)); + + this.beacons.set(beacon.identifier, beacon); } /** * @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.beaconInfoId); + .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 { diff --git a/src/models/room.ts b/src/models/room.ts index 9f1d9afbc09..a02b4f9511a 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1474,7 +1474,7 @@ export class Room 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); } @@ -2339,22 +2339,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/models/thread.ts b/src/models/thread.ts index 549336b4f31..7d4f70c53b3 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,9 +116,10 @@ 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); @@ -161,6 +163,43 @@ export class Thread extends TypedEventEmitter { this.emit(ThreadEvent.Update, this); }; + 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) => { + 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 + const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); + if (!this.lastEvent || this.lastEvent.isRedacted() || (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); + } + } + + this.emit(ThreadEvent.Update, this); + }; + public get roomState(): RoomState { return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS); } 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/sync.ts b/src/sync.ts index 1621c8f253b..30df2cb7e6c 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. @@ -1641,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..80929c36e5a 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. @@ -425,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; } @@ -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); +}