1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-12-01 04:43:29 +03:00

Merge remote-tracking branch 'upstream/develop' into hs/upload-limits

This commit is contained in:
Will Hunt
2018-10-16 11:32:21 +01:00
38 changed files with 2933 additions and 633 deletions

View File

@@ -1,3 +1,242 @@
Changes in [0.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.0) (2018-10-16)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.0-rc.1...v0.12.0)
* No changes since rc.1
Changes in [0.12.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.0-rc.1) (2018-10-11)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.1...v0.12.0-rc.1)
BREAKING CHANGES
----------------
* If js-sdk finds data in the store that is incompatible with the options currently being used,
it will emit sync state ERROR with an error of type InvalidStoreError. It will also stop trying
to sync in this situation: the app must stop the client and then either clear the store or
change the options (in this case, enable or disable lazy loading of members) and then start
the client again.
All Changes
-----------
* never replace /sync'ed memberships with OOB ones
[\#760](https://github.com/matrix-org/matrix-js-sdk/pull/760)
* Don't fail to start up if lazy load check fails
[\#759](https://github.com/matrix-org/matrix-js-sdk/pull/759)
* Make e2e work on Edge
[\#754](https://github.com/matrix-org/matrix-js-sdk/pull/754)
* throw error with same name and message over idb worker boundary
[\#758](https://github.com/matrix-org/matrix-js-sdk/pull/758)
* Default to a room version of 1 when there is no room create event
[\#755](https://github.com/matrix-org/matrix-js-sdk/pull/755)
* Silence bluebird warnings
[\#757](https://github.com/matrix-org/matrix-js-sdk/pull/757)
* allow non-ff merge from release branch into master
[\#750](https://github.com/matrix-org/matrix-js-sdk/pull/750)
* Reject with the actual error on indexeddb error
[\#751](https://github.com/matrix-org/matrix-js-sdk/pull/751)
* Update mocha to v5
[\#744](https://github.com/matrix-org/matrix-js-sdk/pull/744)
* disable lazy loading for guests as they cant create filters
[\#748](https://github.com/matrix-org/matrix-js-sdk/pull/748)
* Revert "Add getMediaLimits to client"
[\#745](https://github.com/matrix-org/matrix-js-sdk/pull/745)
Changes in [0.11.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1) (2018-10-01)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.1-rc.1...v0.11.1)
* No changes since rc.1
Changes in [0.11.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1-rc.1) (2018-09-27)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0...v0.11.1-rc.1)
* make usage of hub compatible with latest version (2.5)
[\#747](https://github.com/matrix-org/matrix-js-sdk/pull/747)
* Detect when lazy loading has been toggled in client.startClient
[\#746](https://github.com/matrix-org/matrix-js-sdk/pull/746)
* Add getMediaLimits to client
[\#644](https://github.com/matrix-org/matrix-js-sdk/pull/644)
* Split npm start into an init and watch script
[\#742](https://github.com/matrix-org/matrix-js-sdk/pull/742)
* Revert "room name should only take canonical alias into account"
[\#738](https://github.com/matrix-org/matrix-js-sdk/pull/738)
* fix display name disambiguation with LL
[\#737](https://github.com/matrix-org/matrix-js-sdk/pull/737)
* Introduce Room.myMembership event
[\#735](https://github.com/matrix-org/matrix-js-sdk/pull/735)
* room name should only take canonical alias into account
[\#733](https://github.com/matrix-org/matrix-js-sdk/pull/733)
* state events from context response were not wrapped in a MatrixEvent
[\#732](https://github.com/matrix-org/matrix-js-sdk/pull/732)
* Reduce amount of promises created when inserting members
[\#724](https://github.com/matrix-org/matrix-js-sdk/pull/724)
* dont wait for LL members to be stored to resolve the members
[\#726](https://github.com/matrix-org/matrix-js-sdk/pull/726)
* RoomState.members emitted with wrong argument order for OOB members
[\#728](https://github.com/matrix-org/matrix-js-sdk/pull/728)
Changes in [0.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.0) (2018-09-10)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0-rc.1...v0.11.0)
BREAKING CHANGES
----------------
* v0.11.0-rc.1 introduced some breaking changes - see the respective release notes.
No changes since rc.1
Changes in [0.11.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.0-rc.1) (2018-09-07)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9...v0.11.0-rc.1)
* Support for lazy loading members. This should improve performance for
users who joined big rooms a lot. Pass to `lazyLoadMembers = true` option when calling `startClient`.
BREAKING CHANGES
----------------
* `MatrixClient::startClient` now returns a Promise. No method should be called on the client before that promise resolves. Before this method didn't return anything.
* A new `CATCHUP` sync state, emitted by `MatrixClient#"sync"` and returned by `MatrixClient::getSyncState()`, when doing initial sync after the `ERROR` state. See `MatrixClient` documentation for details.
* `RoomState::maySendEvent('m.room.message', userId)` & `RoomState::maySendMessage(userId)` do not check the membership of the user anymore, only the power level. To check if the syncing user is allowed to write in a room, use `Room::maySendMessage()` as `RoomState` is not always aware of the syncing user's membership anymore, in case lazy loading of members is enabled.
All Changes
-----------
* Only emit CATCHUP if recovering from conn error
[\#727](https://github.com/matrix-org/matrix-js-sdk/pull/727)
* Fix docstring for sync data.error
[\#725](https://github.com/matrix-org/matrix-js-sdk/pull/725)
* Re-apply "Don't rely on members to query if syncing user can post to room"
[\#723](https://github.com/matrix-org/matrix-js-sdk/pull/723)
* Revert "Don't rely on members to query if syncing user can post to room"
[\#721](https://github.com/matrix-org/matrix-js-sdk/pull/721)
* Don't rely on members to query if syncing user can post to room
[\#717](https://github.com/matrix-org/matrix-js-sdk/pull/717)
* Fixes for room.guessDMUserId
[\#719](https://github.com/matrix-org/matrix-js-sdk/pull/719)
* Fix filepanel also filtering main timeline with LL turned on.
[\#716](https://github.com/matrix-org/matrix-js-sdk/pull/716)
* Remove lazy loaded members when leaving room
[\#711](https://github.com/matrix-org/matrix-js-sdk/pull/711)
* Fix: show spinner again while recovering from connection error
[\#702](https://github.com/matrix-org/matrix-js-sdk/pull/702)
* Add method to query LL state in client
[\#714](https://github.com/matrix-org/matrix-js-sdk/pull/714)
* Fix: also load invited members when lazy loading members
[\#707](https://github.com/matrix-org/matrix-js-sdk/pull/707)
* Pass through function to discard megolm session
[\#704](https://github.com/matrix-org/matrix-js-sdk/pull/704)
Changes in [0.10.9](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9) (2018-09-03)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9-rc.2...v0.10.9)
* No changes since rc.2
Changes in [0.10.9-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9-rc.2) (2018-08-31)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9-rc.1...v0.10.9-rc.2)
* Fix for "otherMember.getAvatarUrl is not a function"
[\#708](https://github.com/matrix-org/matrix-js-sdk/pull/708)
Changes in [0.10.9-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9-rc.1) (2018-08-30)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.8...v0.10.9-rc.1)
* Fix DM avatar
[\#706](https://github.com/matrix-org/matrix-js-sdk/pull/706)
* Lazy loading: avoid loading members at initial sync for e2e rooms
[\#699](https://github.com/matrix-org/matrix-js-sdk/pull/699)
* Improve setRoomEncryption guard against multiple m.room.encryption st…
[\#700](https://github.com/matrix-org/matrix-js-sdk/pull/700)
* Revert "Lazy loading: don't block on setting up room crypto"
[\#698](https://github.com/matrix-org/matrix-js-sdk/pull/698)
* Lazy loading: don't block on setting up room crypto
[\#696](https://github.com/matrix-org/matrix-js-sdk/pull/696)
* Add getVisibleRooms()
[\#695](https://github.com/matrix-org/matrix-js-sdk/pull/695)
* Add wrapper around getJoinedMemberCount()
[\#697](https://github.com/matrix-org/matrix-js-sdk/pull/697)
* Api to fetch events via /room/.../event/..
[\#694](https://github.com/matrix-org/matrix-js-sdk/pull/694)
* Support for room upgrades
[\#693](https://github.com/matrix-org/matrix-js-sdk/pull/693)
* Lazy loading of room members
[\#691](https://github.com/matrix-org/matrix-js-sdk/pull/691)
Changes in [0.10.8](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.8) (2018-08-20)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.8-rc.1...v0.10.8)
* No changes since rc.1
Changes in [0.10.8-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.8-rc.1) (2018-08-16)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.7...v0.10.8-rc.1)
* Add getVersion to Room
[\#689](https://github.com/matrix-org/matrix-js-sdk/pull/689)
* Add getSyncStateData()
[\#680](https://github.com/matrix-org/matrix-js-sdk/pull/680)
* Send sync error to listener
[\#679](https://github.com/matrix-org/matrix-js-sdk/pull/679)
* make sure room.tags is always a valid object to avoid crashes
[\#675](https://github.com/matrix-org/matrix-js-sdk/pull/675)
* Fix infinite spinner upon joining a room
[\#673](https://github.com/matrix-org/matrix-js-sdk/pull/673)
Changes in [0.10.7](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.7) (2018-07-30)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.7-rc.1...v0.10.7)
* No changes since rc.1
Changes in [0.10.7-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.7-rc.1) (2018-07-24)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6...v0.10.7-rc.1)
* encrypt for invited users if history visibility allows.
[\#666](https://github.com/matrix-org/matrix-js-sdk/pull/666)
Changes in [0.10.6](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.6) (2018-07-09)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6-rc.1...v0.10.6)
* No changes since rc.1
Changes in [0.10.6-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.6-rc.1) (2018-07-06)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.5...v0.10.6-rc.1)
* Expose event decryption error via Event.decrypted event
[\#665](https://github.com/matrix-org/matrix-js-sdk/pull/665)
* Add decryption error codes to base.DecryptionError
[\#663](https://github.com/matrix-org/matrix-js-sdk/pull/663)
Changes in [0.10.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.5) (2018-06-29)
==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.5-rc.1...v0.10.5)
* No changes since rc.1
Changes in [0.10.5-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.5-rc.1) (2018-06-21)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.4...v0.10.5-rc.1)
* fix auth header and filename=undefined
[\#659](https://github.com/matrix-org/matrix-js-sdk/pull/659)
* allow setting the output device for webrtc calls
[\#650](https://github.com/matrix-org/matrix-js-sdk/pull/650)
* arguments true and false are actually invalid
[\#596](https://github.com/matrix-org/matrix-js-sdk/pull/596)
* fix typo where `headers` was not being used and thus sent wrong content-type
[\#643](https://github.com/matrix-org/matrix-js-sdk/pull/643)
* fix some documentation typos
[\#642](https://github.com/matrix-org/matrix-js-sdk/pull/642)
Changes in [0.10.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.4) (2018-06-12) Changes in [0.10.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.4) (2018-06-12)
================================================================================================== ==================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.4-rc.1...v0.10.4) [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.4-rc.1...v0.10.4)

View File

@@ -30,9 +30,61 @@ In Node.js
console.log("Public Rooms: %s", JSON.stringify(data)); console.log("Public Rooms: %s", JSON.stringify(data));
}); });
``` ```
See below for how to include libolm to enable end-to-end-encryption. Please check See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node) for a more complex example. [the Node.js terminal app](examples/node) for a more complex example.
To start the client:
```javascript
await client.startClient({initialSyncLimit: 10});
```
You can perform a call to `/sync` to get the current state of the client:
```javascript
client.once('sync', function(state, prevState, res) {
if(state === 'PREPARED') {
console.log("prepared");
} else {
console.log(state);
process.exit(1);
}
});
```
To send a message:
```javascript
var content = {
"body": "message text",
"msgtype": "m.text"
};
client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
console.log(err);
});
```
To listen for message events:
```javascript
client.on("Room.timeline", function(event, room, toStartOfTimeline) {
if (event.getType() !== "m.room.message") {
return; // only use messages
}
console.log(event.event.content.body);
});
```
By default, the `matrix-js-sdk` client uses the `MatrixInMemoryStore` to store events as they are received. For example to iterate through the currently stored timeline for a room:
```javascript
Object.keys(client.store.rooms).forEach((roomId) => {
client.getRoom(roomId).timeline.forEach(t => {
console.log(t.event);
});
});
```
What does this SDK do? What does this SDK do?
---------------------- ----------------------

View File

@@ -202,9 +202,9 @@ function printRoomList() {
dateStr = new Date(msg.getTs()).toISOString().replace( dateStr = new Date(msg.getTs()).toISOString().replace(
/T/, ' ').replace(/\..+/, ''); /T/, ' ').replace(/\..+/, '');
} }
var me = roomList[i].getMember(myUserId); var myMembership = roomList[i].getMyMembership();
if (me) { if (myMembership) {
fmt = fmts[me.membership]; fmt = fmts[myMembership];
} }
var roomName = fixWidth(roomList[i].name, 25); var roomName = fixWidth(roomList[i].name, 25);
print( print(

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "0.10.4", "version": "0.12.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -10,7 +10,9 @@
"test": "npm run test:build && npm run test:run", "test": "npm run test:build && npm run test:run",
"check": "npm run test:build && _mocha --recursive specbuild --colors", "check": "npm run test:build && _mocha --recursive specbuild --colors",
"gendoc": "babel --no-babelrc -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc", "gendoc": "babel --no-babelrc -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc",
"start": "babel -s -w -d lib src", "start": "npm run start:init && npm run start:watch",
"start:watch": "babel -s -w --skip-initial-build -d lib src",
"start:init": "babel -s -d lib src",
"clean": "rimraf lib dist", "clean": "rimraf lib dist",
"build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js", "build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js",
"dist": "npm run build", "dist": "npm run build",
@@ -72,8 +74,8 @@
"jsdoc": "^3.5.5", "jsdoc": "^3.5.5",
"lolex": "^1.5.2", "lolex": "^1.5.2",
"matrix-mock-request": "^1.2.0", "matrix-mock-request": "^1.2.0",
"mocha": "^3.2.0", "mocha": "^5.2.0",
"mocha-jenkins-reporter": "^0.3.6", "mocha-jenkins-reporter": "^0.4.0",
"rimraf": "^2.5.4", "rimraf": "^2.5.4",
"source-map-support": "^0.4.11", "source-map-support": "^0.4.11",
"sourceify": "^0.1.0", "sourceify": "^0.1.0",

View File

@@ -245,7 +245,7 @@ release_text=`mktemp`
echo "$tag" > "${release_text}" echo "$tag" > "${release_text}"
echo >> "${release_text}" echo >> "${release_text}"
cat "${latest_changes}" >> "${release_text}" cat "${latest_changes}" >> "${release_text}"
hub release create $hubflags $assets -f "${release_text}" "$tag" hub release create $hubflags $assets -F "${release_text}" "$tag"
if [ $dodist -eq 0 ]; then if [ $dodist -eq 0 ]; then
rm -rf "$builddir" rm -rf "$builddir"
@@ -281,7 +281,7 @@ fi
echo "updating master branch" echo "updating master branch"
git checkout master git checkout master
git pull git pull
git merge --ff-only "$rel_branch" git merge "$rel_branch"
# push master and docs (if generated) to github # push master and docs (if generated) to github
git push origin master git push origin master

View File

@@ -159,7 +159,7 @@ describe("MatrixClient", function() {
describe("joinRoom", function() { describe("joinRoom", function() {
it("should no-op if you've already joined a room", function() { it("should no-op if you've already joined a room", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const room = new Room(roomId); const room = new Room(roomId, userId);
room.addLiveEvents([ room.addLiveEvents([
utils.mkMembership({ utils.mkMembership({
user: userId, room: roomId, mship: "join", event: true, user: userId, room: roomId, mship: "join", event: true,

View File

@@ -94,7 +94,7 @@ describe("MatrixClient opts", function() {
httpBackend.flush("/txn1", 1); httpBackend.flush("/txn1", 1);
}); });
it("should be able to sync / get new events", function(done) { it("should be able to sync / get new events", async function() {
const expectedEventTypes = [ // from /initialSync const expectedEventTypes = [ // from /initialSync
"m.room.message", "m.room.name", "m.room.member", "m.room.member", "m.room.message", "m.room.name", "m.room.member", "m.room.member",
"m.room.create", "m.room.create",
@@ -110,20 +110,16 @@ describe("MatrixClient opts", function() {
httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" }); httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" });
httpBackend.when("GET", "/sync").respond(200, syncData); httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient(); await client.startClient();
httpBackend.flush("/pushrules", 1).then(function() { await httpBackend.flush("/pushrules", 1);
return httpBackend.flush("/filter", 1); await httpBackend.flush("/filter", 1);
}).then(function() { await Promise.all([
return Promise.all([ httpBackend.flush("/sync", 1),
httpBackend.flush("/sync", 1), utils.syncPromise(client),
utils.syncPromise(client), ]);
]); expect(expectedEventTypes.length).toEqual(
}).done(function() { 0, "Expected to see event types: " + expectedEventTypes,
expect(expectedEventTypes.length).toEqual( );
0, "Expected to see event types: " + expectedEventTypes,
);
done();
});
}); });
}); });

View File

@@ -139,6 +139,9 @@ describe("MatrixClient", function() {
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
store.getClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
store.storeClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
store.isNewlyCreated = expect.createSpy().andReturn(Promise.resolve(true));
client = new MatrixClient({ client = new MatrixClient({
baseUrl: "https://my.home.server", baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl, idBaseUrl: identityServerUrl,
@@ -182,7 +185,7 @@ describe("MatrixClient", function() {
}); });
}); });
it("should not POST /filter if a matching filter already exists", function(done) { it("should not POST /filter if a matching filter already exists", async function() {
httpLookups = []; httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push(SYNC_RESPONSE); httpLookups.push(SYNC_RESPONSE);
@@ -191,15 +194,19 @@ describe("MatrixClient", function() {
const filter = new sdk.Filter(0, filterId); const filter = new sdk.Filter(0, filterId);
filter.setDefinition({"room": {"timeline": {"limit": 8}}}); filter.setDefinition({"room": {"timeline": {"limit": 8}}});
store.getFilter.andReturn(filter); store.getFilter.andReturn(filter);
client.startClient(); const syncPromise = new Promise((resolve, reject) => {
client.on("sync", function syncListener(state) {
client.on("sync", function syncListener(state) { if (state === "SYNCING") {
if (state === "SYNCING") { expect(httpLookups.length).toEqual(0);
expect(httpLookups.length).toEqual(0); client.removeListener("sync", syncListener);
client.removeListener("sync", syncListener); resolve();
done(); } else if (state === "ERROR") {
} reject(new Error("sync error"));
}
});
}); });
await client.startClient();
await syncPromise;
}); });
describe("getSyncState", function() { describe("getSyncState", function() {
@@ -207,15 +214,18 @@ describe("MatrixClient", function() {
expect(client.getSyncState()).toBe(null); expect(client.getSyncState()).toBe(null);
}); });
it("should return the same sync state as emitted sync events", function(done) { it("should return the same sync state as emitted sync events", async function() {
client.on("sync", function syncListener(state) { const syncingPromise = new Promise((resolve) => {
expect(state).toEqual(client.getSyncState()); client.on("sync", function syncListener(state) {
if (state === "SYNCING") { expect(state).toEqual(client.getSyncState());
client.removeListener("sync", syncListener); if (state === "SYNCING") {
done(); client.removeListener("sync", syncListener);
} resolve();
}
});
}); });
client.startClient(); await client.startClient();
await syncingPromise;
}); });
}); });
@@ -258,8 +268,8 @@ describe("MatrixClient", function() {
}); });
describe("retryImmediately", function() { describe("retryImmediately", function() {
it("should return false if there is no request waiting", function() { it("should return false if there is no request waiting", async function() {
client.startClient(); await client.startClient();
expect(client.retryImmediately()).toBe(false); expect(client.retryImmediately()).toBe(false);
}); });
@@ -380,7 +390,7 @@ describe("MatrixClient", function() {
client.startClient(); client.startClient();
}); });
it("should transition ERROR -> PREPARED after /sync if prev failed", it("should transition ERROR -> CATCHUP after /sync if prev failed",
function(done) { function(done) {
const expectedStates = []; const expectedStates = [];
acceptKeepalives = false; acceptKeepalives = false;
@@ -403,7 +413,7 @@ describe("MatrixClient", function() {
expectedStates.push(["RECONNECTING", null]); expectedStates.push(["RECONNECTING", null]);
expectedStates.push(["ERROR", "RECONNECTING"]); expectedStates.push(["ERROR", "RECONNECTING"]);
expectedStates.push(["PREPARED", "ERROR"]); expectedStates.push(["CATCHUP", "ERROR"]);
client.on("sync", syncChecker(expectedStates, done)); client.on("sync", syncChecker(expectedStates, done));
client.startClient(); client.startClient();
}); });

View File

@@ -192,6 +192,15 @@ describe("RoomMember", function() {
}); });
}); });
describe("isOutOfBand", function() {
it("should be set by markOutOfBand", function() {
const member = new RoomMember();
expect(member.isOutOfBand()).toEqual(false);
member.markOutOfBand();
expect(member.isOutOfBand()).toEqual(true);
});
});
describe("setMembershipEvent", function() { describe("setMembershipEvent", function() {
const joinEvent = utils.mkMembership({ const joinEvent = utils.mkMembership({
event: true, event: true,

View File

@@ -11,6 +11,9 @@ describe("RoomState", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const userA = "@alice:bar"; const userA = "@alice:bar";
const userB = "@bob:bar"; const userB = "@bob:bar";
const userC = "@cleo:bar";
const userLazy = "@lazy:bar";
let state; let state;
beforeEach(function() { beforeEach(function() {
@@ -78,8 +81,8 @@ describe("RoomState", function() {
}); });
describe("getSentinelMember", function() { describe("getSentinelMember", function() {
it("should return null if there is no member", function() { it("should return a member with the user id as name", function() {
expect(state.getSentinelMember("@no-one:here")).toEqual(null); expect(state.getSentinelMember("@no-one:here").name).toEqual("@no-one:here");
}); });
it("should return a member which doesn't change when the state is updated", it("should return a member which doesn't change when the state is updated",
@@ -162,6 +165,7 @@ describe("RoomState", function() {
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.newMember", function(ev, st, mem) { state.on("RoomState.newMember", function(ev, st, mem) {
expect(state.getMember(mem.userId)).toEqual(mem);
expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); expect(mem.userId).toEqual(memberEvents[emitCount].getSender());
expect(mem.membership).toBeFalsy(); // not defined yet expect(mem.membership).toBeFalsy(); // not defined yet
emitCount += 1; emitCount += 1;
@@ -222,7 +226,6 @@ describe("RoomState", function() {
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", it("should call setPowerLevelEvent on a new RoomMember if power levels exist",
function() { function() {
const userC = "@cleo:bar";
const memberEvent = utils.mkMembership({ const memberEvent = utils.mkMembership({
mship: "join", user: userC, room: roomId, event: true, mship: "join", user: userC, room: roomId, event: true,
}); });
@@ -262,6 +265,114 @@ describe("RoomState", function() {
}); });
}); });
describe("setOutOfBandMembers", function() {
it("should add a new member", function() {
const oobMemberEvent = utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const member = state.getMember(userLazy);
expect(member.userId).toEqual(userLazy);
expect(member.isOutOfBand()).toEqual(true);
});
it("should have no effect when not in correct status", function() {
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
});
it("should emit newMember when adding a member", function() {
const userLazy = "@oob:hs";
const oobMemberEvent = utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
});
let eventReceived = false;
state.once('RoomState.newMember', (_, __, member) => {
expect(member.userId).toEqual(userLazy);
eventReceived = true;
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
expect(eventReceived).toEqual(true);
});
it("should never overwrite existing members", function() {
const oobMemberEvent = utils.mkMembership({
user: userA, mship: "join", room: roomId, event: true,
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const memberA = state.getMember(userA);
expect(memberA.events.member.getId()).toNotEqual(oobMemberEvent.getId());
expect(memberA.isOutOfBand()).toEqual(false);
});
it("should emit members when updating a member", function() {
const doesntExistYetUserId = "@doesntexistyet:hs";
const oobMemberEvent = utils.mkMembership({
user: doesntExistYetUserId, mship: "join", room: roomId, event: true,
});
let eventReceived = false;
state.once('RoomState.members', (_, __, member) => {
expect(member.userId).toEqual(doesntExistYetUserId);
eventReceived = true;
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
expect(eventReceived).toEqual(true);
});
});
describe("clone", function() {
it("should contain same information as original", function() {
// include OOB members in copy
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
const copy = state.clone();
// check individual members
[userA, userB, userLazy].forEach((userId) => {
const member = state.getMember(userId);
const memberCopy = copy.getMember(userId);
expect(member.name).toEqual(memberCopy.name);
expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand());
});
// check member keys
expect(Object.keys(state.members)).toEqual(Object.keys(copy.members));
// check join count
expect(state.getJoinedMemberCount()).toEqual(copy.getJoinedMemberCount());
});
it("should mark old copy as not waiting for out of band anymore", function() {
state.markOutOfBandMembersStarted();
const copy = state.clone();
copy.setOutOfBandMembers([utils.mkMembership({
user: userA, mship: "join", room: roomId, event: true,
})]);
// should have no effect as it should be marked in status finished just like copy
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
});
it("should return copy independent of original", function() {
const copy = state.clone();
copy.setStateEvents([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
expect(state.getJoinedMemberCount()).toEqual(2);
expect(copy.getJoinedMemberCount()).toEqual(3);
});
});
describe("setTypingEvent", function() { describe("setTypingEvent", function() {
it("should call setTypingEvent on each RoomMember", function() { it("should call setTypingEvent on each RoomMember", function() {
const typingEvent = utils.mkEvent({ const typingEvent = utils.mkEvent({
@@ -284,13 +395,6 @@ describe("RoomState", function() {
}); });
describe("maySendStateEvent", function() { describe("maySendStateEvent", function() {
it("should say non-joined members may not send state",
function() {
expect(state.maySendStateEvent(
'm.room.name', "@nobody:nowhere",
)).toEqual(false);
});
it("should say any member may send state with no power level event", it("should say any member may send state with no power level event",
function() { function() {
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
@@ -366,15 +470,117 @@ describe("RoomState", function() {
}); });
}); });
describe("maySendEvent", function() { describe("getJoinedMemberCount", function() {
it("should say non-joined members may not send events", beforeEach(() => {
function() { state = new RoomState(roomId);
expect(state.maySendEvent(
'm.room.message', "@nobody:nowhere",
)).toEqual(false);
expect(state.maySendMessage("@nobody:nowhere")).toEqual(false);
}); });
it("should update after adding joined member", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userA, room: roomId}),
]);
expect(state.getJoinedMemberCount()).toEqual(1);
state.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userC, room: roomId}),
]);
expect(state.getJoinedMemberCount()).toEqual(2);
});
});
describe("getInvitedMemberCount", function() {
beforeEach(() => {
state = new RoomState(roomId);
});
it("should update after adding invited member", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userA, room: roomId}),
]);
expect(state.getInvitedMemberCount()).toEqual(1);
state.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userC, room: roomId}),
]);
expect(state.getInvitedMemberCount()).toEqual(2);
});
});
describe("setJoinedMemberCount", function() {
beforeEach(() => {
state = new RoomState(roomId);
});
it("should, once used, override counting members from state", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userA, room: roomId}),
]);
expect(state.getJoinedMemberCount()).toEqual(1);
state.setJoinedMemberCount(100);
expect(state.getJoinedMemberCount()).toEqual(100);
state.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userC, room: roomId}),
]);
expect(state.getJoinedMemberCount()).toEqual(100);
});
it("should, once used, override counting members from state, " +
"also after clone", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userA, room: roomId}),
]);
state.setJoinedMemberCount(100);
const copy = state.clone();
copy.setStateEvents([
utils.mkMembership({event: true, mship: "join",
user: userC, room: roomId}),
]);
expect(state.getJoinedMemberCount()).toEqual(100);
});
});
describe("setInvitedMemberCount", function() {
beforeEach(() => {
state = new RoomState(roomId);
});
it("should, once used, override counting members from state", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userB, room: roomId}),
]);
expect(state.getInvitedMemberCount()).toEqual(1);
state.setInvitedMemberCount(100);
expect(state.getInvitedMemberCount()).toEqual(100);
state.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userC, room: roomId}),
]);
expect(state.getInvitedMemberCount()).toEqual(100);
});
it("should, once used, override counting members from state, " +
"also after clone", function() {
state.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userB, room: roomId}),
]);
state.setInvitedMemberCount(100);
const copy = state.clone();
copy.setStateEvents([
utils.mkMembership({event: true, mship: "invite",
user: userC, room: roomId}),
]);
expect(state.getInvitedMemberCount()).toEqual(100);
});
});
describe("maySendEvent", function() {
it("should say any member may send events with no power level event", it("should say any member may send events with no power level event",
function() { function() {
expect(state.maySendEvent('m.room.message', userA)).toEqual(true); expect(state.maySendEvent('m.room.message', userA)).toEqual(true);

View File

@@ -67,13 +67,14 @@ describe("Room", function() {
describe("getMember", function() { describe("getMember", function() {
beforeEach(function() { beforeEach(function() {
// clobber members property with test data room.currentState.getMember.andCall(function(userId) {
room.currentState.members = { return {
"@alice:bar": { "@alice:bar": {
userId: userA, userId: userA,
roomId: roomId, roomId: roomId,
}, },
}; }[userId];
});
}); });
it("should return null if the member isn't in current state", function() { it("should return null if the member isn't in current state", function() {
@@ -386,7 +387,7 @@ describe("Room", function() {
let events = null; let events = null;
beforeEach(function() { beforeEach(function() {
room = new Room(roomId, {timelineSupport: timelineSupport}); room = new Room(roomId, null, null, {timelineSupport: timelineSupport});
// set events each time to avoid resusing Event objects (which // set events each time to avoid resusing Event objects (which
// doesn't work because they get frozen) // doesn't work because they get frozen)
events = [ events = [
@@ -468,7 +469,7 @@ describe("Room", function() {
describe("compareEventOrdering", function() { describe("compareEventOrdering", function() {
beforeEach(function() { beforeEach(function() {
room = new Room(roomId, {timelineSupport: true}); room = new Room(roomId, null, null, {timelineSupport: true});
}); });
const events = [ const events = [
@@ -570,72 +571,75 @@ describe("Room", function() {
describe("hasMembershipState", function() { describe("hasMembershipState", function() {
it("should return true for a matching userId and membership", it("should return true for a matching userId and membership",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, return {
"@bob:bar": { userId: "@bob:bar", membership: "invite" }, "@alice:bar": { userId: "@alice:bar", membership: "join" },
}; "@bob:bar": { userId: "@bob:bar", membership: "invite" },
}[userId];
});
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true);
}); });
it("should return false if match membership but no match userId", it("should return false if match membership but no match userId",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, return {
}; "@alice:bar": { userId: "@alice:bar", membership: "join" },
}[userId];
});
expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); expect(room.hasMembershipState("@bob:bar", "join")).toBe(false);
}); });
it("should return false if match userId but no match membership", it("should return false if match userId but no match membership",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, return {
}; "@alice:bar": { userId: "@alice:bar", membership: "join" },
}[userId];
});
expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false);
}); });
it("should return false if no match membership or userId", it("should return false if no match membership or userId",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, return {
}; "@alice:bar": { userId: "@alice:bar", membership: "join" },
}[userId];
});
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false);
}); });
it("should return false if no members exist", it("should return false if no members exist",
function() { function() {
room.currentState.members = {};
expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); expect(room.hasMembershipState("@foo:bar", "join")).toBe(false);
}); });
}); });
describe("recalculate", function() { describe("recalculate", function() {
let stateLookup = {
// event.type + "$" event.state_key : MatrixEvent
};
const setJoinRule = function(rule) { const setJoinRule = function(rule) {
stateLookup["m.room.join_rules$"] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.join_rules", room: roomId, user: userA, content: { type: "m.room.join_rules", room: roomId, user: userA, content: {
join_rule: rule, join_rule: rule,
}, event: true, }, event: true,
}); })]);
}; };
const setAliases = function(aliases, stateKey) { const setAliases = function(aliases, stateKey) {
if (!stateKey) { if (!stateKey) {
stateKey = "flibble"; stateKey = "flibble";
} }
stateLookup["m.room.aliases$" + stateKey] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.aliases", room: roomId, skey: stateKey, content: { type: "m.room.aliases", room: roomId, skey: stateKey, content: {
aliases: aliases, aliases: aliases,
}, event: true, }, event: true,
}); })]);
}; };
const setRoomName = function(name) { const setRoomName = function(name) {
stateLookup["m.room.name$"] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.name", room: roomId, user: userA, content: { type: "m.room.name", room: roomId, user: userA, content: {
name: name, name: name,
}, event: true, }, event: true,
}); })]);
}; };
const addMember = function(userId, state, opts) { const addMember = function(userId, state, opts) {
if (!state) { if (!state) {
@@ -647,56 +651,14 @@ describe("Room", function() {
opts.user = opts.user || userId; opts.user = opts.user || userId;
opts.skey = userId; opts.skey = userId;
opts.event = true; opts.event = true;
stateLookup["m.room.member$" + userId] = utils.mkMembership(opts); const event = utils.mkMembership(opts);
room.addLiveEvents([event]);
return event;
}; };
beforeEach(function() { beforeEach(function() {
stateLookup = {}; // no mocking
room.currentState.getStateEvents.andCall(function(type, key) { room = new Room(roomId, null, userA);
if (key === undefined) {
const prefix = type + "$";
const list = [];
for (const stateBlob in stateLookup) {
if (!stateLookup.hasOwnProperty(stateBlob)) {
continue;
}
if (stateBlob.indexOf(prefix) === 0) {
list.push(stateLookup[stateBlob]);
}
}
return list;
} else {
return stateLookup[type + "$" + key];
}
});
room.currentState.getMembers.andCall(function() {
const memberEvents = room.currentState.getStateEvents("m.room.member");
const members = [];
for (let i = 0; i < memberEvents.length; i++) {
members.push({
name: memberEvents[i].event.content &&
memberEvents[i].event.content.displayname ?
memberEvents[i].event.content.displayname :
memberEvents[i].getStateKey(),
userId: memberEvents[i].getStateKey(),
events: { member: memberEvents[i] },
});
}
return members;
});
room.currentState.getMember.andCall(function(userId) {
const memberEvent = room.currentState.getStateEvents(
"m.room.member", userId,
);
return {
name: memberEvent.event.content &&
memberEvent.event.content.displayname ?
memberEvent.event.content.displayname :
memberEvent.getStateKey(),
userId: memberEvent.getStateKey(),
events: { member: memberEvent },
};
});
}); });
describe("Room.recalculate => Stripped State Events", function() { describe("Room.recalculate => Stripped State Events", function() {
@@ -704,8 +666,8 @@ describe("Room", function() {
"room is an invite room", function() { "room is an invite room", function() {
const roomName = "flibble"; const roomName = "flibble";
addMember(userA, "invite"); const event = addMember(userA, "invite");
stateLookup["m.room.member$" + userA].event.invite_room_state = [ event.event.invite_room_state = [
{ {
type: "m.room.name", type: "m.room.name",
state_key: "", state_key: "",
@@ -715,30 +677,108 @@ describe("Room", function() {
}, },
]; ];
room.recalculate(userA); room.recalculate();
expect(room.currentState.setStateEvents).toHaveBeenCalled(); expect(room.name).toEqual(roomName);
// first call, first arg (which is an array), first element in array
const fakeEvent = room.currentState.setStateEvents.calls[0].
arguments[0][0];
expect(fakeEvent.getContent()).toEqual({
name: roomName,
});
}); });
it("should not clobber state events if it isn't an invite room", function() { it("should not clobber state events if it isn't an invite room", function() {
addMember(userA, "join"); const event = addMember(userA, "join");
stateLookup["m.room.member$" + userA].event.invite_room_state = [ const roomName = "flibble";
setRoomName(roomName);
const roomNameToIgnore = "ignoreme";
event.event.invite_room_state = [
{ {
type: "m.room.name", type: "m.room.name",
state_key: "", state_key: "",
content: { content: {
name: "flibble", name: roomNameToIgnore,
}, },
}, },
]; ];
room.recalculate(userA); room.recalculate();
expect(room.currentState.setStateEvents).toNotHaveBeenCalled(); expect(room.name).toEqual(roomName);
});
});
describe("Room.recalculate => Room Name using room summary", function() {
it("should use room heroes if available", function() {
addMember(userA, "invite");
addMember(userB);
addMember(userC);
addMember(userD);
room.setSummary({
"m.heroes": [userB, userC, userD],
});
room.recalculate();
expect(room.name).toEqual(`${userB} and 2 others`);
});
it("missing hero member state reverts to mxid", function() {
room.setSummary({
"m.heroes": [userB],
"m.joined_member_count": 2,
});
room.recalculate();
expect(room.name).toEqual(userB);
});
it("uses hero name from state", function() {
const name = "Mr B";
addMember(userA, "invite");
addMember(userB, "join", {name});
room.setSummary({
"m.heroes": [userB],
});
room.recalculate();
expect(room.name).toEqual(name);
});
it("uses counts from summary", function() {
const name = "Mr B";
addMember(userB, "join", {name});
room.setSummary({
"m.heroes": [userB],
"m.joined_member_count": 50,
"m.invited_member_count": 50,
});
room.recalculate();
expect(room.name).toEqual(`${name} and 98 others`);
});
it("relies on heroes in case of absent counts", function() {
const nameB = "Mr Bean";
const nameC = "Mel C";
addMember(userB, "join", {name: nameB});
addMember(userC, "join", {name: nameC});
room.setSummary({
"m.heroes": [userB, userC],
});
room.recalculate();
expect(room.name).toEqual(`${nameB} and ${nameC}`);
});
it("uses only heroes", function() {
const nameB = "Mr Bean";
addMember(userB, "join", {name: nameB});
addMember(userC, "join");
room.setSummary({
"m.heroes": [userB],
});
room.recalculate();
expect(room.name).toEqual(nameB);
});
it("reverts to empty room in case of self chat", function() {
room.setSummary({
"m.heroes": [],
"m.invited_member_count": 1,
});
room.recalculate();
expect(room.name).toEqual("Empty room");
}); });
}); });
@@ -751,7 +791,7 @@ describe("Room", function() {
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
addMember(userD); addMember(userD);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
// we expect at least 1 member to be mentioned // we expect at least 1 member to be mentioned
const others = [userB, userC, userD]; const others = [userB, userC, userD];
@@ -772,7 +812,7 @@ describe("Room", function() {
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
expect(name.indexOf(userC)).toNotEqual(-1, name); expect(name.indexOf(userC)).toNotEqual(-1, name);
@@ -785,7 +825,7 @@ describe("Room", function() {
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
expect(name.indexOf(userC)).toNotEqual(-1, name); expect(name.indexOf(userC)).toNotEqual(-1, name);
@@ -797,7 +837,7 @@ describe("Room", function() {
setJoinRule("public"); setJoinRule("public");
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -808,7 +848,7 @@ describe("Room", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -818,7 +858,7 @@ describe("Room", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA, "invite", {user: userB}); addMember(userA, "invite", {user: userB});
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -828,7 +868,7 @@ describe("Room", function() {
const alias = "#room_alias:here"; const alias = "#room_alias:here";
setJoinRule("invite"); setJoinRule("invite");
setAliases([alias, "#another:one"]); setAliases([alias, "#another:one"]);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(alias); expect(name).toEqual(alias);
}); });
@@ -838,7 +878,7 @@ describe("Room", function() {
const alias = "#room_alias:here"; const alias = "#room_alias:here";
setJoinRule("public"); setJoinRule("public");
setAliases([alias, "#another:one"]); setAliases([alias, "#another:one"]);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(alias); expect(name).toEqual(alias);
}); });
@@ -848,7 +888,7 @@ describe("Room", function() {
const roomName = "A mighty name indeed"; const roomName = "A mighty name indeed";
setJoinRule("invite"); setJoinRule("invite");
setRoomName(roomName); setRoomName(roomName);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(roomName); expect(name).toEqual(roomName);
}); });
@@ -858,25 +898,23 @@ describe("Room", function() {
const roomName = "A mighty name indeed"; const roomName = "A mighty name indeed";
setJoinRule("public"); setJoinRule("public");
setRoomName(roomName); setRoomName(roomName);
room.recalculate(userA); room.recalculate();
const name = room.name; expect(room.name).toEqual(roomName);
expect(name).toEqual(roomName);
}); });
it("should return 'Empty room' for private (invite join_rules) rooms if" + it("should return 'Empty room' for private (invite join_rules) rooms if" +
" a room name and alias don't exist and it is a self-chat.", function() { " a room name and alias don't exist and it is a self-chat.", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userA);
room.recalculate(userA); room.recalculate();
const name = room.name; expect(room.name).toEqual("Empty room");
expect(name).toEqual("Empty room");
}); });
it("should return 'Empty room' for public (public join_rules) rooms if a" + it("should return 'Empty room' for public (public join_rules) rooms if a" +
" room name and alias don't exist and it is a self-chat.", function() { " room name and alias don't exist and it is a self-chat.", function() {
setJoinRule("public"); setJoinRule("public");
addMember(userA); addMember(userA);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Empty room"); expect(name).toEqual("Empty room");
}); });
@@ -884,7 +922,7 @@ describe("Room", function() {
it("should return 'Empty room' if there is no name, " + it("should return 'Empty room' if there is no name, " +
"alias or members in the room.", "alias or members in the room.",
function() { function() {
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Empty room"); expect(name).toEqual("Empty room");
}); });
@@ -893,9 +931,9 @@ describe("Room", function() {
"available", "available",
function() { function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA, 'join', {name: "Alice"}); addMember(userB, 'join', {name: "Alice"});
addMember(userB, "invite", {user: userA}); addMember(userA, "invite", {user: userA});
room.recalculate(userB); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Alice"); expect(name).toEqual("Alice");
}); });
@@ -903,11 +941,11 @@ describe("Room", function() {
it("should return inviter mxid if display name not available", it("should return inviter mxid if display name not available",
function() { function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userB);
addMember(userB, "invite", {user: userA}); addMember(userA, "invite", {user: userA});
room.recalculate(userB); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(userA); expect(name).toEqual(userB);
}); });
}); });
}); });
@@ -1154,7 +1192,7 @@ describe("Room", function() {
describe("addPendingEvent", function() { describe("addPendingEvent", function() {
it("should add pending events to the pendingEventList if " + it("should add pending events to the pendingEventList if " +
"pendingEventOrdering == 'detached'", function() { "pendingEventOrdering == 'detached'", function() {
const room = new Room(roomId, { const room = new Room(roomId, null, userA, {
pendingEventOrdering: "detached", pendingEventOrdering: "detached",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1180,7 +1218,7 @@ describe("Room", function() {
it("should add pending events to the timeline if " + it("should add pending events to the timeline if " +
"pendingEventOrdering == 'chronological'", function() { "pendingEventOrdering == 'chronological'", function() {
room = new Room(roomId, { room = new Room(roomId, null, userA, {
pendingEventOrdering: "chronological", pendingEventOrdering: "chronological",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1204,7 +1242,7 @@ describe("Room", function() {
describe("updatePendingEvent", function() { describe("updatePendingEvent", function() {
it("should remove cancelled events from the pending list", function() { it("should remove cancelled events from the pending list", function() {
const room = new Room(roomId, { const room = new Room(roomId, null, userA, {
pendingEventOrdering: "detached", pendingEventOrdering: "detached",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1240,7 +1278,7 @@ describe("Room", function() {
it("should remove cancelled events from the timeline", function() { it("should remove cancelled events from the timeline", function() {
const room = new Room(roomId); const room = new Room(roomId, null, userA);
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}); });
@@ -1272,4 +1310,153 @@ describe("Room", function() {
expect(callCount).toEqual(1); expect(callCount).toEqual(1);
}); });
}); });
describe("loadMembersIfNeeded", function() {
function createClientMock(serverResponse, storageResponse = null) {
return {
getEventMapper: function() {
// events should already be MatrixEvents
return function(event) {return event;};
},
isRoomEncrypted: function() {
return false;
},
_http: {
serverResponse,
authedRequest: function() {
if (this.serverResponse instanceof Error) {
return Promise.reject(this.serverResponse);
} else {
return Promise.resolve({chunk: this.serverResponse});
}
},
},
store: {
storageResponse,
storedMembers: null,
getOutOfBandMembers: function() {
if (this.storageResponse instanceof Error) {
return Promise.reject(this.storageResponse);
} else {
return Promise.resolve(this.storageResponse);
}
},
setOutOfBandMembers: function(roomId, memberEvents) {
this.storedMembers = memberEvents;
return Promise.resolve();
},
getSyncToken: () => "sync_token",
},
};
}
const memberEvent = utils.mkMembership({
user: "@user_a:bar", mship: "join",
room: roomId, event: true, name: "User A",
});
it("should load members from server on first call", async function() {
const client = createClientMock([memberEvent]);
const room = new Room(roomId, client, null, {lazyLoadMembers: true});
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar");
expect(memberA.name).toEqual("User A");
const storedMembers = client.store.storedMembers;
expect(storedMembers.length).toEqual(1);
expect(storedMembers[0].event_id).toEqual(memberEvent.getId());
});
it("should take members from storage if available", async function() {
const memberEvent2 = utils.mkMembership({
user: "@user_a:bar", mship: "join",
room: roomId, event: true, name: "Ms A",
});
const client = createClientMock([memberEvent2], [memberEvent]);
const room = new Room(roomId, client, null, {lazyLoadMembers: true});
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar");
expect(memberA.name).toEqual("User A");
});
it("should allow retry on error", async function() {
const client = createClientMock(new Error("server says no"));
const room = new Room(roomId, client, null, {lazyLoadMembers: true});
let hasThrown = false;
try {
await room.loadMembersIfNeeded();
} catch(err) {
hasThrown = true;
}
expect(hasThrown).toEqual(true);
client._http.serverResponse = [memberEvent];
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar");
expect(memberA.name).toEqual("User A");
});
});
describe("getMyMembership", function() {
it("should return synced membership if membership isn't available yet",
function() {
const room = new Room(roomId, null, userA);
room.updateMyMembership("invite");
expect(room.getMyMembership()).toEqual("invite");
});
it("should emit a Room.myMembership event on a change",
function() {
const room = new Room(roomId, null, userA);
const events = [];
room.on("Room.myMembership", (_room, membership, oldMembership) => {
events.push({membership, oldMembership});
});
room.updateMyMembership("invite");
expect(room.getMyMembership()).toEqual("invite");
expect(events[0]).toEqual({membership: "invite", oldMembership: null});
events.splice(0); //clear
room.updateMyMembership("invite");
expect(events.length).toEqual(0);
room.updateMyMembership("join");
expect(room.getMyMembership()).toEqual("join");
expect(events[0]).toEqual({membership: "join", oldMembership: "invite"});
});
});
describe("guessDMUserId", function() {
it("should return first hero id",
function() {
const room = new Room(roomId, null, userA);
room.setSummary({'m.heroes': [userB]});
expect(room.guessDMUserId()).toEqual(userB);
});
it("should return first member that isn't self",
function() {
const room = new Room(roomId, null, userA);
room.addLiveEvents([utils.mkMembership({
user: userB, mship: "join",
room: roomId, event: true,
})]);
expect(room.guessDMUserId()).toEqual(userB);
});
it("should return self if only member present",
function() {
const room = new Room(roomId, null, userA);
expect(room.guessDMUserId()).toEqual(userA);
});
});
describe("maySendMessage", function() {
it("should return false if synced membership not join",
function() {
const room = new Room(roomId, null, userA);
room.updateMyMembership("invite");
expect(room.maySendMessage()).toEqual(false);
room.updateMyMembership("leave");
expect(room.maySendMessage()).toEqual(false);
room.updateMyMembership("join");
expect(room.maySendMessage()).toEqual(true);
});
});
}); });

View File

@@ -52,6 +52,11 @@ describe("SyncAccumulator", function() {
member("bob", "join"), member("bob", "join"),
], ],
}, },
summary: {
"m.heroes": undefined,
"m.joined_member_count": undefined,
"m.invited_member_count": undefined,
},
timeline: { timeline: {
events: [msg("alice", "hi")], events: [msg("alice", "hi")],
prev_batch: "something", prev_batch: "something",
@@ -318,6 +323,58 @@ describe("SyncAccumulator", function() {
}, },
}); });
}); });
describe("summary field", function() {
function createSyncResponseWithSummary(summary) {
return {
next_batch: "abc",
rooms: {
invite: {},
leave: {},
join: {
"!foo:bar": {
account_data: { events: [] },
ephemeral: { events: [] },
unread_notifications: {},
state: {
events: [],
},
summary: summary,
timeline: {
events: [],
prev_batch: "something",
},
},
},
},
};
}
it("should copy summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}));
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
expect(summary["m.invited_member_count"]).toEqual(2);
expect(summary["m.heroes"]).toEqual(["@alice:bar"]);
});
it("should accumulate summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}));
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@bob:bar"],
"m.joined_member_count": 5,
}));
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
expect(summary["m.invited_member_count"]).toEqual(2);
expect(summary["m.joined_member_count"]).toEqual(5);
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
});
});
}); });
function syncSkeleton(joinObj) { function syncSkeleton(joinObj) {

View File

@@ -417,6 +417,69 @@ MatrixBaseApis.prototype.roomState = function(roomId, callback) {
return this._http.authedRequest(callback, "GET", path); return this._http.authedRequest(callback, "GET", path);
}; };
/**
* Get an event in a room by its event id.
* @param {string} roomId
* @param {string} eventId
* @param {module:client.callback} callback Optional.
*
* @return {Promise} Resolves to an object containing the event.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.fetchRoomEvent = function(roomId, eventId, callback) {
const path = utils.encodeUri(
"/rooms/$roomId/event/$eventId", {
$roomId: roomId,
$eventId: eventId,
},
);
return this._http.authedRequest(callback, "GET", path);
};
/**
* @param {string} roomId
* @param {string} includeMembership the membership type to include in the response
* @param {string} excludeMembership the membership type to exclude from the response
* @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: dictionary of userid to profile information
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.members =
function(roomId, includeMembership, excludeMembership, atEventId, callback) {
const queryParams = {};
if (includeMembership) {
queryParams.membership = includeMembership;
}
if (excludeMembership) {
queryParams.not_membership = excludeMembership;
}
if (atEventId) {
queryParams.at = atEventId;
}
const queryString = utils.encodeParams(queryParams);
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{$roomId: roomId});
return this._http.authedRequest(callback, "GET", path);
};
/**
* Upgrades a room to a new protocol version
* @param {string} roomId
* @param {string} newVersion The target version to upgrade to
* @return {module:client.Promise} Resolves: Object with key 'replacement_room'
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.upgradeRoom = function(roomId, newVersion) {
const path = utils.encodeUri("/rooms/$roomId/upgrade", {$roomId: roomId});
return this._http.authedRequest(
undefined, "POST", path, undefined, {new_version: newVersion},
);
};
/** /**
* @param {string} groupId * @param {string} groupId
* @return {module:client.Promise} Resolves: Group summary object * @return {module:client.Promise} Resolves: Group summary object

View File

@@ -45,6 +45,11 @@ const ContentHelpers = require("./content-helpers");
import ReEmitter from './ReEmitter'; import ReEmitter from './ReEmitter';
import RoomList from './crypto/RoomList'; import RoomList from './crypto/RoomList';
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
Promise.config({warnings: false});
const SCROLLBACK_DELAY_MS = 3000; const SCROLLBACK_DELAY_MS = 3000;
let CRYPTO_ENABLED = false; let CRYPTO_ENABLED = false;
@@ -191,6 +196,8 @@ function MatrixClient(opts) {
// The pushprocessor caches useful things, so keep one and re-use it // The pushprocessor caches useful things, so keep one and re-use it
this._pushProcessor = new PushProcessor(this); this._pushProcessor = new PushProcessor(this);
this._serverSupportsLazyLoading = null;
} }
utils.inherits(MatrixClient, EventEmitter); utils.inherits(MatrixClient, EventEmitter);
utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype); utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype);
@@ -287,6 +294,21 @@ MatrixClient.prototype.getSyncState = function() {
return this._syncApi.getSyncState(); return this._syncApi.getSyncState();
}; };
/**
* Returns the additional data object associated with
* the current sync state, or null if there is no
* such data.
* Sync errors, if available, are put in the 'error' key of
* this object.
* @return {?Object}
*/
MatrixClient.prototype.getSyncStateData = function() {
if (!this._syncApi) {
return null;
}
return this._syncApi.getSyncStateData();
};
/** /**
* Return whether the client is configured for a guest account. * Return whether the client is configured for a guest account.
* @return {boolean} True if this is a guest access_token (or no token is supplied). * @return {boolean} True if this is a guest access_token (or no token is supplied).
@@ -673,6 +695,21 @@ MatrixClient.prototype.isRoomEncrypted = function(roomId) {
return this._roomList.isRoomEncrypted(roomId); return this._roomList.isRoomEncrypted(roomId);
}; };
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.
*
* @param {string} roomId The ID of the room to discard the session for
*
* This should not normally be necessary.
*/
MatrixClient.prototype.forceDiscardSession = function(roomId) {
if (!this._crypto) {
throw new Error("End-to-End encryption disabled");
}
this._crypto.forceDiscardSession(roomId);
};
/** /**
* Get a list containing all of the room keys * Get a list containing all of the room keys
* *
@@ -761,6 +798,37 @@ MatrixClient.prototype.getRooms = function() {
return this.store.getRooms(); return this.store.getRooms();
}; };
/**
* Retrieve all rooms that should be displayed to the user
* This is essentially getRooms() with some rooms filtered out, eg. old versions
* of rooms that have been replaced or (in future) other rooms that have been
* marked at the protocol level as not to be displayed to the user.
* @return {Room[]} A list of rooms, or an empty list if there is no data store.
*/
MatrixClient.prototype.getVisibleRooms = function() {
const allRooms = this.store.getRooms();
const replacedRooms = new Set();
for (const r of allRooms) {
const createEvent = r.currentState.getStateEvents('m.room.create', '');
// invites are included in this list and we don't know their create events yet
if (createEvent) {
const predecessor = createEvent.getContent()['predecessor'];
if (predecessor && predecessor['room_id']) {
replacedRooms.add(predecessor['room_id']);
}
}
}
return allRooms.filter((r) => {
const tombstone = r.currentState.getStateEvents('m.room.tombstone', '');
if (tombstone && replacedRooms.has(r.roomId)) {
return false;
}
return true;
});
};
/** /**
* Retrieve a user. * Retrieve a user.
* @param {string} userId The user ID to retrieve. * @param {string} userId The user ID to retrieve.
@@ -1930,14 +1998,6 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
// reduce the required number of events appropriately // reduce the required number of events appropriately
limit = limit - numAdded; limit = limit - numAdded;
const path = utils.encodeUri(
"/rooms/$roomId/messages", {$roomId: room.roomId},
);
const params = {
from: room.oldState.paginationToken,
limit: limit,
dir: 'b',
};
const defer = Promise.defer(); const defer = Promise.defer();
info = { info = {
promise: defer.promise, promise: defer.promise,
@@ -1947,9 +2007,17 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
// wait for a time before doing this request // wait for a time before doing this request
// (which may be 0 in order not to special case the code paths) // (which may be 0 in order not to special case the code paths)
Promise.delay(timeToWaitMs).then(function() { Promise.delay(timeToWaitMs).then(function() {
return self._http.authedRequest(callback, "GET", path, params); return self._createMessagesRequest(
room.roomId,
room.oldState.paginationToken,
limit,
'b');
}).done(function(res) { }).done(function(res) {
const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self)); const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
if (res.state) {
const stateEvents = utils.map(res.state, _PojoToMatrixEventMapper(self));
room.currentState.setUnknownStateEvents(stateEvents);
}
room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline()); room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline());
room.oldState.paginationToken = res.end; room.oldState.paginationToken = res.end;
if (res.chunk.length === 0) { if (res.chunk.length === 0) {
@@ -1968,73 +2036,6 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
return defer.promise; return defer.promise;
}; };
/**
* Take an EventContext, and back/forward-fill results.
*
* @param {module:models/event-context.EventContext} eventContext context
* object to be updated
* @param {Object} opts
* @param {boolean} opts.backwards true to fill backwards, false to go forwards
* @param {boolean} opts.limit number of events to request
*
* @return {module:client.Promise} Resolves: updated EventContext object
* @return {Error} Rejects: with an error response.
*/
MatrixClient.prototype.paginateEventContext = function(eventContext, opts) {
// TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors.
opts = opts || {};
const backwards = opts.backwards || false;
const token = eventContext.getPaginateToken(backwards);
if (!token) {
// no more results.
return Promise.reject(new Error("No paginate token"));
}
const dir = backwards ? 'b' : 'f';
const pendingRequest = eventContext._paginateRequests[dir];
if (pendingRequest) {
// already a request in progress - return the existing promise
return pendingRequest;
}
const path = utils.encodeUri(
"/rooms/$roomId/messages", {$roomId: eventContext.getEvent().getRoomId()},
);
const params = {
from: token,
limit: ('limit' in opts) ? opts.limit : 30,
dir: dir,
};
const self = this;
const promise =
self._http.authedRequest(undefined, "GET", path, params,
).then(function(res) {
let token = res.end;
if (res.chunk.length === 0) {
token = null;
} else {
const matrixEvents = utils.map(res.chunk, self.getEventMapper());
if (backwards) {
// eventContext expects the events in timeline order, but
// back-pagination returns them in reverse order.
matrixEvents.reverse();
}
eventContext.addEvents(matrixEvents, backwards);
}
eventContext.setPaginateToken(token, backwards);
return eventContext;
}).finally(function() {
eventContext._paginateRequests[dir] = null;
});
eventContext._paginateRequests[dir] = promise;
return promise;
};
/** /**
* Get an EventTimeline for the given event * Get an EventTimeline for the given event
* *
@@ -2068,11 +2069,16 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
}, },
); );
let params = undefined;
if (this._clientOpts.lazyLoadMembers) {
params = {filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)};
}
// TODO: we should implement a backoff (as per scrollback()) to deal more // TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors. // nicely with HTTP errors.
const self = this; const self = this;
const promise = const promise =
self._http.authedRequest(undefined, "GET", path, self._http.authedRequest(undefined, "GET", path, params,
).then(function(res) { ).then(function(res) {
if (!res.event) { if (!res.event) {
throw new Error("'event' not in '/context' result - homeserver too old?"); throw new Error("'event' not in '/context' result - homeserver too old?");
@@ -2099,6 +2105,9 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
timeline.initialiseState(utils.map(res.state, timeline.initialiseState(utils.map(res.state,
self.getEventMapper())); self.getEventMapper()));
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
} else {
const stateEvents = utils.map(res.state, self.getEventMapper());
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents);
} }
timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start); timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);
@@ -2113,6 +2122,49 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
return promise; return promise;
}; };
/**
* Makes a request to /messages with the appropriate lazy loading filter set.
* XXX: if we do get rid of scrollback (as it's not used at the moment),
* we could inline this method again in paginateEventTimeline as that would
* then be the only call-site
* @param {string} roomId
* @param {string} fromToken
* @param {number} limit the maximum amount of events the retrieve
* @param {string} dir 'f' or 'b'
* @param {Filter} timelineFilter the timeline filter to pass
* @return {Promise}
*/
MatrixClient.prototype._createMessagesRequest =
function(roomId, fromToken, limit, dir, timelineFilter = undefined) {
const path = utils.encodeUri(
"/rooms/$roomId/messages", {$roomId: roomId},
);
if (limit === undefined) {
limit = 30;
}
const params = {
from: fromToken,
limit: limit,
dir: dir,
};
let filter = null;
if (this._clientOpts.lazyLoadMembers) {
// create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
// so the timelineFilter doesn't get written into it below
filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER);
}
if (timelineFilter) {
// XXX: it's horrific that /messages' filter parameter doesn't match
// /sync's one - see https://matrix.org/jira/browse/SPEC-451
filter = filter || {};
Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent());
}
if (filter) {
params.filter = JSON.stringify(filter);
}
return this._http.authedRequest(undefined, "GET", path, params);
};
/** /**
* Take an EventTimeline, and back/forward-fill results. * Take an EventTimeline, and back/forward-fill results.
@@ -2207,25 +2259,18 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
throw new Error("Unknown room " + eventTimeline.getRoomId()); throw new Error("Unknown room " + eventTimeline.getRoomId());
} }
path = utils.encodeUri( promise = this._createMessagesRequest(
"/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()}, eventTimeline.getRoomId(),
); token,
params = { opts.limit,
from: token, dir,
limit: ('limit' in opts) ? opts.limit : 30, eventTimeline.getFilter());
dir: dir, promise.then(function(res) {
}; if (res.state) {
const roomState = eventTimeline.getState(dir);
const filter = eventTimeline.getFilter(); const stateEvents = utils.map(res.state, self.getEventMapper());
if (filter) { roomState.setUnknownStateEvents(stateEvents);
// XXX: it's horrific that /messages' filter parameter doesn't match }
// /sync's one - see https://matrix.org/jira/browse/SPEC-451
params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent());
}
promise =
this._http.authedRequest(undefined, "GET", path, params,
).then(function(res) {
const token = res.end; const token = res.end;
const matrixEvents = utils.map(res.chunk, self.getEventMapper()); const matrixEvents = utils.map(res.chunk, self.getEventMapper());
eventTimeline.getTimelineSet() eventTimeline.getTimelineSet()
@@ -3030,8 +3075,11 @@ MatrixClient.prototype.getTurnServers = function() {
* *
* @param {Boolean=} opts.disablePresence True to perform syncing without automatically * @param {Boolean=} opts.disablePresence True to perform syncing without automatically
* updating presence. * updating presence.
* @param {Boolean=} opts.lazyLoadMembers True to not load all membership events during
* initial sync but fetch them when needed by calling `loadOutOfBandMembers`
* This will override the filter option at this moment.
*/ */
MatrixClient.prototype.startClient = function(opts) { MatrixClient.prototype.startClient = async function(opts) {
if (this.clientRunning) { if (this.clientRunning) {
// client is already running. // client is already running.
return; return;
@@ -3069,11 +3117,29 @@ MatrixClient.prototype.startClient = function(opts) {
return this._canResetTimelineCallback(roomId); return this._canResetTimelineCallback(roomId);
}; };
this._clientOpts = opts; this._clientOpts = opts;
this._syncApi = new SyncApi(this, opts); this._syncApi = new SyncApi(this, opts);
this._syncApi.sync(); this._syncApi.sync();
}; };
/**
* store client options with boolean/string/numeric values
* to know in the next session what flags the sync data was
* created with (e.g. lazy loading)
* @param {object} opts the complete set of client options
* @return {Promise} for store operation */
MatrixClient.prototype._storeClientOptions = function() {
const primTypes = ["boolean", "string", "number"];
const serializableOpts = Object.entries(this._clientOpts)
.filter(([key, value]) => {
return primTypes.includes(typeof value);
})
.reduce((obj, [key, value]) => {
obj[key] = value;
return obj;
}, {});
return this.store.storeClientOptions(serializableOpts);
};
/** /**
* High level helper method to stop the client from polling and allow a * High level helper method to stop the client from polling and allow a
* clean shutdown. * clean shutdown.
@@ -3096,6 +3162,36 @@ MatrixClient.prototype.stopClient = function() {
global.clearTimeout(this._checkTurnServersTimeoutID); global.clearTimeout(this._checkTurnServersTimeoutID);
}; };
/*
* Query the server to see if it support members lazy loading
* @return {Promise<boolean>} true if server supports lazy loading
*/
MatrixClient.prototype.doesServerSupportLazyLoading = async function() {
if (this._serverSupportsLazyLoading === null) {
const response = await this._http.request(
undefined, // callback
"GET", "/_matrix/client/versions",
undefined, // queryParams
undefined, // data
{
prefix: '',
},
);
const unstableFeatures = response["unstable_features"];
this._serverSupportsLazyLoading =
unstableFeatures && unstableFeatures["m.lazy_load_members"];
}
return this._serverSupportsLazyLoading;
};
/*
* Get if lazy loading members is being used.
* @return {boolean} Whether or not members are lazy loaded by this client
*/
MatrixClient.prototype.hasLazyLoadMembersEnabled = function() {
return !!this._clientOpts.lazyLoadMembers;
};
/* /*
* Set a function which is called when /sync returns a 'limited' response. * Set a function which is called when /sync returns a 'limited' response.
* It is called with a room ID and returns a boolean. It should return 'true' if the SDK * It is called with a room ID and returns a boolean. It should return 'true' if the SDK
@@ -3441,6 +3537,12 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* a state of SYNCING. <i>This is the equivalent of "syncComplete" in the * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
* previous API.</i></li> * previous API.</i></li>
* *
* <li>CATCHUP: The client has detected the connection to the server might be
* available again and will now try to do a sync again. As this sync might take
* a long time (depending how long ago was last synced, and general server
* performance) the client is put in this mode so the UI can reflect trying
* to catch up with the server after losing connection.</li>
*
* <li>SYNCING : The client is currently polling for new events from the server. * <li>SYNCING : The client is currently polling for new events from the server.
* This will be called <i>after</i> processing latest events from a sync.</li> * This will be called <i>after</i> processing latest events from a sync.</li>
* *
@@ -3464,11 +3566,11 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* +---->STOPPED * +---->STOPPED
* | * |
* +----->PREPARED -------> SYNCING <--+ * +----->PREPARED -------> SYNCING <--+
* | ^ | ^ | * | ^ | ^ |
* | | | | | * | CATCHUP ----------+ | | |
* | | V | | * | ^ V | |
* null ------+ | +--------RECONNECTING | * null ------+ | +------- RECONNECTING |
* | | V | * | V V |
* +------->ERROR ---------------------+ * +------->ERROR ---------------------+
* *
* NB: 'null' will never be emitted by this event. * NB: 'null' will never be emitted by this event.
@@ -3518,7 +3620,7 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* *
* @param {?Object} data Data about this transition. * @param {?Object} data Data about this transition.
* *
* @param {MatrixError} data.err The matrix error if <code>state=ERROR</code>. * @param {MatrixError} data.error The matrix error if <code>state=ERROR</code>.
* *
* @param {String} data.oldSyncToken The 'since' token passed to /sync. * @param {String} data.oldSyncToken The 'since' token passed to /sync.
* <code>null</code> for the first successful sync since this client was * <code>null</code> for the first successful sync since this client was

View File

@@ -71,6 +71,9 @@ export default class RoomList {
} }
async setRoomEncryption(roomId, roomInfo) { async setRoomEncryption(roomId, roomInfo) {
// important that this happens before calling into the store
// as it prevents the Crypto::setRoomEncryption from calling
// this twice for consecutive m.room.encryption events
this._roomEncryption[roomId] = roomInfo; this._roomEncryption[roomId] = roomInfo;
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {

View File

@@ -176,8 +176,9 @@ export {DecryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272
* @extends Error * @extends Error
*/ */
class DecryptionError extends Error { class DecryptionError extends Error {
constructor(msg, details) { constructor(code, msg, details) {
super(msg); super(msg);
this.code = code;
this.name = 'DecryptionError'; this.name = 'DecryptionError';
this.detailedString = _detailedStringForDecryptionError(this, details); this.detailedString = _detailedStringForDecryptionError(this, details);
} }

View File

@@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -488,6 +489,8 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
session_id: session.sessionId, session_id: session.sessionId,
// Include our device ID so that recipients can send us a // Include our device ID so that recipients can send us a
// m.new_device message if they don't have our session key. // m.new_device message if they don't have our session key.
// XXX: Do we still need this now that m.new_device messages
// no longer exist since #483?
device_id: self._deviceId, device_id: self._deviceId,
}; };
@@ -496,6 +499,16 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
}); });
}; };
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.
*
* This should not normally be necessary.
*/
MegolmEncryption.prototype.forceDiscardSession = function() {
this._setupPromise = this._setupPromise.then(() => null);
};
/** /**
* Checks the devices we're about to send to and see if any are entirely * Checks the devices we're about to send to and see if any are entirely
* unknown to the user. If so, warn the user, and mark them as known to * unknown to the user. If so, warn the user, and mark them as known to
@@ -535,9 +548,9 @@ MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) {
* @return {module:client.Promise} Promise which resolves to a map * @return {module:client.Promise} Promise which resolves to a map
* from userId to deviceId to deviceInfo * from userId to deviceId to deviceInfo
*/ */
MegolmEncryption.prototype._getDevicesInRoom = function(room) { MegolmEncryption.prototype._getDevicesInRoom = async function(room) {
// XXX what about rooms where invitees can see the content? const members = await room.getEncryptionTargetMembers();
const roomMembers = utils.map(room.getJoinedMembers(), function(u) { const roomMembers = utils.map(members, function(u) {
return u.userId; return u.userId;
}); });
@@ -550,35 +563,31 @@ MegolmEncryption.prototype._getDevicesInRoom = function(room) {
// We are happy to use a cached version here: we assume that if we already // We are happy to use a cached version here: we assume that if we already
// have a list of the user's devices, then we already share an e2e room // have a list of the user's devices, then we already share an e2e room
// with them, which means that they will have announced any new devices via // with them, which means that they will have announced any new devices via
// an m.new_device. // device_lists in their /sync response. This cache should then be maintained
// // using all the device_lists changes and left fields.
// XXX: what if the cache is stale, and the user left the room we had in // See https://github.com/vector-im/riot-web/issues/2305 for details.
// common and then added new devices before joining this one? --Matthew const devices = await this._crypto.downloadKeys(roomMembers, false);
// // remove any blocked devices
// yup, see https://github.com/vector-im/riot-web/issues/2305 --richvdh for (const userId in devices) {
return this._crypto.downloadKeys(roomMembers, false).then((devices) => { if (!devices.hasOwnProperty(userId)) {
// remove any blocked devices continue;
for (const userId in devices) { }
if (!devices.hasOwnProperty(userId)) {
const userDevices = devices[userId];
for (const deviceId in userDevices) {
if (!userDevices.hasOwnProperty(deviceId)) {
continue; continue;
} }
const userDevices = devices[userId]; if (userDevices[deviceId].isBlocked() ||
for (const deviceId in userDevices) { (userDevices[deviceId].isUnverified() && isBlacklisting)
if (!userDevices.hasOwnProperty(deviceId)) { ) {
continue; delete userDevices[deviceId];
}
if (userDevices[deviceId].isBlocked() ||
(userDevices[deviceId].isUnverified() && isBlacklisting)
) {
delete userDevices[deviceId];
}
} }
} }
}
return devices; return devices;
});
}; };
/** /**
@@ -618,7 +627,10 @@ MegolmDecryption.prototype.decryptEvent = async function(event) {
if (!content.sender_key || !content.session_id || if (!content.sender_key || !content.session_id ||
!content.ciphertext !content.ciphertext
) { ) {
throw new base.DecryptionError("Missing fields in input"); throw new base.DecryptionError(
"MEGOLM_MISSING_FIELDS",
"Missing fields in input",
);
} }
// we add the event to the pending list *before* we start decryption. // we add the event to the pending list *before* we start decryption.
@@ -635,10 +647,16 @@ MegolmDecryption.prototype.decryptEvent = async function(event) {
event.getId(), event.getTs(), event.getId(), event.getTs(),
); );
} catch (e) { } catch (e) {
let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
if (e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { if (e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') {
this._requestKeysForEvent(event); this._requestKeysForEvent(event);
errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX';
} }
throw new base.DecryptionError( throw new base.DecryptionError(
errorCode,
e.toString(), { e.toString(), {
session: content.sender_key + '|' + content.session_id, session: content.sender_key + '|' + content.session_id,
}, },
@@ -655,6 +673,7 @@ MegolmDecryption.prototype.decryptEvent = async function(event) {
// scheduled, so we needn't send out the request here.) // scheduled, so we needn't send out the request here.)
this._requestKeysForEvent(event); this._requestKeysForEvent(event);
throw new base.DecryptionError( throw new base.DecryptionError(
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
"The sender's device has not sent us the keys for this message.", "The sender's device has not sent us the keys for this message.",
{ {
session: content.sender_key + '|' + content.session_id, session: content.sender_key + '|' + content.session_id,
@@ -673,6 +692,7 @@ MegolmDecryption.prototype.decryptEvent = async function(event) {
// room, so neither the sender nor a MITM can lie about the room_id). // room, so neither the sender nor a MITM can lie about the room_id).
if (payload.room_id !== event.getRoomId()) { if (payload.room_id !== event.getRoomId()) {
throw new base.DecryptionError( throw new base.DecryptionError(
"MEGOLM_BAD_ROOM",
"Message intended for room " + payload.room_id, "Message intended for room " + payload.room_id,
); );
} }

View File

@@ -83,60 +83,62 @@ OlmEncryption.prototype._ensureSession = function(roomMembers) {
* *
* @return {module:client.Promise} Promise which resolves to the new event body * @return {module:client.Promise} Promise which resolves to the new event body
*/ */
OlmEncryption.prototype.encryptMessage = function(room, eventType, content) { OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) {
// pick the list of recipients based on the membership list. // pick the list of recipients based on the membership list.
// //
// TODO: there is a race condition here! What if a new user turns up // TODO: there is a race condition here! What if a new user turns up
// just as you are sending a secret message? // just as you are sending a secret message?
const users = utils.map(room.getJoinedMembers(), function(u) { const members = await room.getEncryptionTargetMembers();
const users = utils.map(members, function(u) {
return u.userId; return u.userId;
}); });
const self = this; const self = this;
return this._ensureSession(users).then(function() { await this._ensureSession(users);
const payloadFields = {
room_id: room.roomId,
type: eventType,
content: content,
};
const encryptedContent = { const payloadFields = {
algorithm: olmlib.OLM_ALGORITHM, room_id: room.roomId,
sender_key: self._olmDevice.deviceCurve25519Key, type: eventType,
ciphertext: {}, content: content,
}; };
const promises = []; const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: self._olmDevice.deviceCurve25519Key,
ciphertext: {},
};
for (let i = 0; i < users.length; ++i) { const promises = [];
const userId = users[i];
const devices = self._crypto.getStoredDevicesForUser(userId);
for (let j = 0; j < devices.length; ++j) { for (let i = 0; i < users.length; ++i) {
const deviceInfo = devices[j]; const userId = users[i];
const key = deviceInfo.getIdentityKey(); const devices = self._crypto.getStoredDevicesForUser(userId);
if (key == self._olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
// don't bother setting up sessions with blocked users
continue;
}
promises.push( for (let j = 0; j < devices.length; ++j) {
olmlib.encryptMessageForDevice( const deviceInfo = devices[j];
encryptedContent.ciphertext, const key = deviceInfo.getIdentityKey();
self._userId, self._deviceId, self._olmDevice, if (key == self._olmDevice.deviceCurve25519Key) {
userId, deviceInfo, payloadFields, // don't bother sending to ourself
), continue;
); }
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
// don't bother setting up sessions with blocked users
continue;
} }
}
return Promise.all(promises).return(encryptedContent); promises.push(
}); olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
self._userId, self._deviceId, self._olmDevice,
userId, deviceInfo, payloadFields,
),
);
}
}
return await Promise.all(promises).return(encryptedContent);
}; };
/** /**
@@ -168,11 +170,17 @@ OlmDecryption.prototype.decryptEvent = async function(event) {
const ciphertext = content.ciphertext; const ciphertext = content.ciphertext;
if (!ciphertext) { if (!ciphertext) {
throw new base.DecryptionError("Missing ciphertext"); throw new base.DecryptionError(
"OLM_MISSING_CIPHERTEXT",
"Missing ciphertext",
);
} }
if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) {
throw new base.DecryptionError("Not included in recipients"); throw new base.DecryptionError(
"OLM_NOT_INCLUDED_IN_RECIPIENTS",
"Not included in recipients",
);
} }
const message = ciphertext[this._olmDevice.deviceCurve25519Key]; const message = ciphertext[this._olmDevice.deviceCurve25519Key];
let payloadString; let payloadString;
@@ -181,6 +189,7 @@ OlmDecryption.prototype.decryptEvent = async function(event) {
payloadString = await this._decryptMessage(deviceKey, message); payloadString = await this._decryptMessage(deviceKey, message);
} catch (e) { } catch (e) {
throw new base.DecryptionError( throw new base.DecryptionError(
"OLM_BAD_ENCRYPTED_MESSAGE",
"Bad Encrypted Message", { "Bad Encrypted Message", {
sender: deviceKey, sender: deviceKey,
err: e, err: e,
@@ -194,12 +203,14 @@ OlmDecryption.prototype.decryptEvent = async function(event) {
// https://github.com/vector-im/vector-web/issues/2483 // https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient != this._userId) { if (payload.recipient != this._userId) {
throw new base.DecryptionError( throw new base.DecryptionError(
"OLM_BAD_RECIPIENT",
"Message was intented for " + payload.recipient, "Message was intented for " + payload.recipient,
); );
} }
if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) {
throw new base.DecryptionError( throw new base.DecryptionError(
"OLM_BAD_RECIPIENT_KEY",
"Message not intended for this device", { "Message not intended for this device", {
intended: payload.recipient_keys.ed25519, intended: payload.recipient_keys.ed25519,
our_key: this._olmDevice.deviceEd25519Key, our_key: this._olmDevice.deviceEd25519Key,
@@ -213,6 +224,7 @@ OlmDecryption.prototype.decryptEvent = async function(event) {
// which is checked elsewhere). // which is checked elsewhere).
if (payload.sender != event.getSender()) { if (payload.sender != event.getSender()) {
throw new base.DecryptionError( throw new base.DecryptionError(
"OLM_FORWARDED_MESSAGE",
"Message forwarded from " + payload.sender, { "Message forwarded from " + payload.sender, {
reported_sender: event.getSender(), reported_sender: event.getSender(),
}, },
@@ -222,6 +234,7 @@ OlmDecryption.prototype.decryptEvent = async function(event) {
// Olm events intended for a room have a room_id. // Olm events intended for a room have a room_id.
if (payload.room_id !== event.getRoomId()) { if (payload.room_id !== event.getRoomId()) {
throw new base.DecryptionError( throw new base.DecryptionError(
"OLM_BAD_ROOM",
"Message intended for room " + payload.room_id, { "Message intended for room " + payload.room_id, {
reported_room: event.room_id, reported_room: event.room_id,
}, },

View File

@@ -106,6 +106,15 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
this._receivedRoomKeyRequestCancellations = []; this._receivedRoomKeyRequestCancellations = [];
// true if we are currently processing received room key requests // true if we are currently processing received room key requests
this._processingRoomKeyRequests = false; this._processingRoomKeyRequests = false;
// controls whether device tracking is delayed
// until calling encryptEvent or trackRoomDevices,
// or done immediately upon enabling room encryption.
this._lazyLoadMembers = false;
// in case _lazyLoadMembers is true,
// track if an initial tracking of all the room members
// has happened for a given room. This is delayed
// to avoid loading room members as long as possible.
this._roomDeviceTrackingState = {};
} }
utils.inherits(Crypto, EventEmitter); utils.inherits(Crypto, EventEmitter);
@@ -167,6 +176,12 @@ Crypto.prototype.init = async function() {
} }
}; };
/**
*/
Crypto.prototype.enableLazyLoading = function() {
this._lazyLoadMembers = true;
};
/** /**
* Tell the crypto module to register for MatrixClient events which it needs to * Tell the crypto module to register for MatrixClient events which it needs to
* listen for * listen for
@@ -606,6 +621,23 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
return device; return device;
}; };
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.
*
* @param {string} roomId The ID of the room to discard the session for
*
* This should not normally be necessary.
*/
Crypto.prototype.forceDiscardSession = function(roomId) {
const alg = this._roomEncryptors[roomId];
if (alg === undefined) throw new Error("Room not encrypted");
if (alg.forceDiscardSession === undefined) {
throw new Error("Room encryption algorithm doesn't support session discarding");
}
alg.forceDiscardSession();
};
/** /**
* Configure a room to use encryption (ie, save a flag in the sessionstore). * Configure a room to use encryption (ie, save a flag in the sessionstore).
* *
@@ -614,25 +646,49 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
* @param {object} config The encryption config for the room. * @param {object} config The encryption config for the room.
* *
* @param {boolean=} inhibitDeviceQuery true to suppress device list query for * @param {boolean=} inhibitDeviceQuery true to suppress device list query for
* users in the room (for now) * users in the room (for now). In case lazy loading is enabled,
* the device query is always inhibited as the members are not tracked.
*/ */
Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) { Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) {
// if we already have encryption in this room, we should ignore this event // if state is being replayed from storage, we might already have a configuration
// (for now at least. maybe we should alert the user somehow?) // for this room as they are persisted as well.
// We just need to make sure the algorithm is initialized in this case.
// However, if the new config is different,
// we should bail out as room encryption can't be changed once set.
const existingConfig = this._roomList.getRoomEncryption(roomId); const existingConfig = this._roomList.getRoomEncryption(roomId);
if (existingConfig && JSON.stringify(existingConfig) != JSON.stringify(config)) { if (existingConfig) {
console.error("Ignoring m.room.encryption event which requests " + if (JSON.stringify(existingConfig) != JSON.stringify(config)) {
"a change of config in " + roomId); console.error("Ignoring m.room.encryption event which requests " +
"a change of config in " + roomId);
return;
}
}
// if we already have encryption in this room, we should ignore this event,
// as it would reset the encryption algorithm.
// This is at least expected to be called twice, as sync calls onCryptoEvent
// for both the timeline and state sections in the /sync response,
// the encryption event would appear in both.
// If it's called more than twice though,
// it signals a bug on client or server.
const existingAlg = this._roomEncryptors[roomId];
if (existingAlg) {
return; return;
} }
// _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption
// because it first stores in memory. We should await the promise only
// after all the in-memory state (_roomEncryptors and _roomList) has been updated
// to avoid races when calling this method multiple times. Hence keep a hold of the promise.
let storeConfigPromise = null;
if(!existingConfig) {
storeConfigPromise = this._roomList.setRoomEncryption(roomId, config);
}
const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
if (!AlgClass) { if (!AlgClass) {
throw new Error("Unable to encrypt with " + config.algorithm); throw new Error("Unable to encrypt with " + config.algorithm);
} }
await this._roomList.setRoomEncryption(roomId, config);
const alg = new AlgClass({ const alg = new AlgClass({
userId: this._userId, userId: this._userId,
deviceId: this._deviceId, deviceId: this._deviceId,
@@ -644,24 +700,59 @@ Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDevic
}); });
this._roomEncryptors[roomId] = alg; this._roomEncryptors[roomId] = alg;
// make sure we are tracking the device lists for all users in this room. if (storeConfigPromise) {
console.log("Enabling encryption in " + roomId + "; " + await storeConfigPromise;
"starting to track device lists for all users therein");
const room = this._clientStore.getRoom(roomId);
if (!room) {
throw new Error(`Unable to enable encryption in unknown room ${roomId}`);
} }
const members = room.getJoinedMembers(); if (!this._lazyLoadMembers) {
members.forEach((m) => { console.log("Enabling encryption in " + roomId + "; " +
this._deviceList.startTrackingDeviceList(m.userId); "starting to track device lists for all users therein");
});
if (!inhibitDeviceQuery) { await this.trackRoomDevices(roomId);
this._deviceList.refreshOutdatedDeviceLists(); // TODO: this flag is only not used from MatrixClient::setRoomEncryption
// which is never used (inside riot at least)
// but didn't want to remove it as it technically would
// be a breaking change.
if(!this.inhibitDeviceQuery) {
this._deviceList.refreshOutdatedDeviceLists();
}
} else {
console.log("Enabling encryption in " + roomId);
} }
}; };
/**
* Make sure we are tracking the device lists for all users in this room.
*
* @param {string} roomId The room ID to start tracking devices in.
* @returns {Promise} when all devices for the room have been fetched and marked to track
*/
Crypto.prototype.trackRoomDevices = function(roomId) {
const trackMembers = async () => {
// not an encrypted room
if (!this._roomEncryptors[roomId]) {
return;
}
const room = this._clientStore.getRoom(roomId);
if (!room) {
throw new Error(`Unable to start tracking devices in unknown room ${roomId}`);
}
console.log(`Starting to track devices for room ${roomId} ...`);
const members = await room.getEncryptionTargetMembers();
members.forEach((m) => {
this._deviceList.startTrackingDeviceList(m.userId);
});
};
let promise = this._roomDeviceTrackingState[roomId];
if (!promise) {
promise = trackMembers();
this._roomDeviceTrackingState[roomId] = promise;
}
return promise;
};
/** /**
* @typedef {Object} module:crypto~OlmSessionResult * @typedef {Object} module:crypto~OlmSessionResult
* @property {module:crypto/deviceinfo} device device info * @property {module:crypto/deviceinfo} device device info
@@ -752,7 +843,7 @@ Crypto.prototype.importRoomKeys = function(keys) {
}, },
); );
}; };
/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307
/** /**
* Encrypt an event according to the configuration of the room. * Encrypt an event according to the configuration of the room.
* *
@@ -763,7 +854,8 @@ Crypto.prototype.importRoomKeys = function(keys) {
* @return {module:client.Promise?} Promise which resolves when the event has been * @return {module:client.Promise?} Promise which resolves when the event has been
* encrypted, or null if nothing was needed * encrypted, or null if nothing was needed
*/ */
Crypto.prototype.encryptEvent = function(event, room) { /* eslint-enable valid-jsdoc */
Crypto.prototype.encryptEvent = async function(event, room) {
if (!room) { if (!room) {
throw new Error("Cannot send encrypted messages in unknown rooms"); throw new Error("Cannot send encrypted messages in unknown rooms");
} }
@@ -781,6 +873,12 @@ Crypto.prototype.encryptEvent = function(event, room) {
); );
} }
if (!this._roomDeviceTrackingState[roomId]) {
this.trackRoomDevices(roomId);
}
// wait for all the room devices to be loaded
await this._roomDeviceTrackingState[roomId];
let content = event.getContent(); let content = event.getContent();
// If event has an m.relates_to then we need // If event has an m.relates_to then we need
// to put this on the wrapping event instead // to put this on the wrapping event instead
@@ -791,20 +889,19 @@ Crypto.prototype.encryptEvent = function(event, room) {
delete content['m.relates_to']; delete content['m.relates_to'];
} }
return alg.encryptMessage( const encryptedContent = await alg.encryptMessage(
room, event.getType(), content, room, event.getType(), content);
).then((encryptedContent) => {
if (mRelatesTo) {
encryptedContent['m.relates_to'] = mRelatesTo;
}
event.makeEncrypted( if (mRelatesTo) {
"m.room.encrypted", encryptedContent['m.relates_to'] = mRelatesTo;
encryptedContent, }
this._olmDevice.deviceCurve25519Key,
this._olmDevice.deviceEd25519Key, event.makeEncrypted(
); "m.room.encrypted",
}); encryptedContent,
this._olmDevice.deviceCurve25519Key,
this._olmDevice.deviceEd25519Key,
);
}; };
/** /**
@@ -852,7 +949,7 @@ Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLi
// If we didn't make this assumption, we'd have to use the /keys/changes API // If we didn't make this assumption, we'd have to use the /keys/changes API
// to get key changes between the sync token in the device list and the 'old' // to get key changes between the sync token in the device list and the 'old'
// sync token used here to make sure we didn't miss any. // sync token used here to make sure we didn't miss any.
this._evalDeviceListChanges(syncDeviceLists); await this._evalDeviceListChanges(syncDeviceLists);
}; };
/** /**
@@ -919,6 +1016,7 @@ Crypto.prototype.onSyncWillProcess = async function(syncData) {
// at which point we'll start tracking all the users of that room. // at which point we'll start tracking all the users of that room.
console.log("Initial sync performed - resetting device tracking state"); console.log("Initial sync performed - resetting device tracking state");
this._deviceList.stopTrackingAllDeviceLists(); this._deviceList.stopTrackingAllDeviceLists();
this._roomDeviceTrackingState = {};
} }
}; };
@@ -964,11 +1062,12 @@ Crypto.prototype._evalDeviceListChanges = async function(deviceLists) {
}); });
} }
if (deviceLists.left && Array.isArray(deviceLists.left)) { if (deviceLists.left && Array.isArray(deviceLists.left) &&
deviceLists.left.length) {
// Check we really don't share any rooms with these users // Check we really don't share any rooms with these users
// any more: the server isn't required to give us the // any more: the server isn't required to give us the
// exact correct set. // exact correct set.
const e2eUserIds = new Set(this._getE2eUsers()); const e2eUserIds = new Set(await this._getTrackedE2eUsers());
deviceLists.left.forEach((u) => { deviceLists.left.forEach((u) => {
if (!e2eUserIds.has(u)) { if (!e2eUserIds.has(u)) {
@@ -980,13 +1079,14 @@ Crypto.prototype._evalDeviceListChanges = async function(deviceLists) {
/** /**
* Get a list of all the IDs of users we share an e2e room with * Get a list of all the IDs of users we share an e2e room with
* for which we are tracking devices already
* *
* @returns {string[]} List of user IDs * @returns {string[]} List of user IDs
*/ */
Crypto.prototype._getE2eUsers = function() { Crypto.prototype._getTrackedE2eUsers = async function() {
const e2eUserIds = []; const e2eUserIds = [];
for (const room of this._getE2eRooms()) { for (const room of this._getTrackedE2eRooms()) {
const members = room.getJoinedMembers(); const members = await room.getEncryptionTargetMembers();
for (const member of members) { for (const member of members) {
e2eUserIds.push(member.userId); e2eUserIds.push(member.userId);
} }
@@ -995,27 +1095,25 @@ Crypto.prototype._getE2eUsers = function() {
}; };
/** /**
* Get a list of the e2e-enabled rooms we are members of * Get a list of the e2e-enabled rooms we are members of,
* and for which we are already tracking the devices
* *
* @returns {module:models.Room[]} * @returns {module:models.Room[]}
*/ */
Crypto.prototype._getE2eRooms = function() { Crypto.prototype._getTrackedE2eRooms = function() {
return this._clientStore.getRooms().filter((room) => { return this._clientStore.getRooms().filter((room) => {
// check for rooms with encryption enabled // check for rooms with encryption enabled
const alg = this._roomEncryptors[room.roomId]; const alg = this._roomEncryptors[room.roomId];
if (!alg) { if (!alg) {
return false; return false;
} }
if (!this._roomDeviceTrackingState[room.roomId]) {
// ignore any rooms which we have left
const me = room.getMember(this._userId);
if (!me || (
me.membership !== "join" && me.membership !== "invite"
)) {
return false; return false;
} }
return true; // ignore any rooms which we have left
const myMembership = room.getMyMembership();
return myMembership === "join" || myMembership === "invite";
}); });
}; };
@@ -1080,11 +1178,20 @@ Crypto.prototype._onRoomMembership = function(event, member, oldMembership) {
// not encrypting in this room // not encrypting in this room
return; return;
} }
// only mark users in this room as tracked if we already started tracking in this room
if (member.membership == 'join') { // this way we don't start device queries after sync on behalf of this room which we won't use
console.log('Join event for ' + member.userId + ' in ' + roomId); // the result of anyway, as we'll need to do a query again once all the members are fetched
// make sure we are tracking the deviceList for this user // by calling _trackRoomDevices
this._deviceList.startTrackingDeviceList(member.userId); if (this._roomDeviceTrackingState[roomId]) {
if (member.membership == 'join') {
console.log('Join event for ' + member.userId + ' in ' + roomId);
// make sure we are tracking the deviceList for this user
this._deviceList.startTrackingDeviceList(member.userId);
} else if (member.membership == 'invite' &&
this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) {
console.log('Invite event for ' + member.userId + ' in ' + roomId);
this._deviceList.startTrackingDeviceList(member.userId);
}
} }
alg.onRoomMembership(event, member, oldMembership); alg.onRoomMembership(event, member, oldMembership);
@@ -1275,6 +1382,7 @@ Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) {
const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm];
if (!AlgClass) { if (!AlgClass) {
throw new algorithms.DecryptionError( throw new algorithms.DecryptionError(
'UNKNOWN_ENCRYPTION_ALGORITHM',
'Unknown encryption algorithm "' + algorithm + '".', 'Unknown encryption algorithm "' + algorithm + '".',
); );
} }

View File

@@ -92,6 +92,19 @@ export default class IndexedDBCryptoStore {
console.log(`connected to indexeddb ${this._dbName}`); console.log(`connected to indexeddb ${this._dbName}`);
resolve(new IndexedDBCryptoStoreBackend.Backend(db)); resolve(new IndexedDBCryptoStoreBackend.Backend(db));
}; };
}).then((backend) => {
// Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
// Try a dummy query which will fail if the browser doesn't support compund keys, so
// we can fall back to a different backend.
return backend.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS],
(txn) => {
backend.getEndToEndInboundGroupSession('', '', txn, () => {});
}).then(() => {
return backend;
},
);
}).catch((e) => { }).catch((e) => {
console.warn( console.warn(
`unable to connect to indexeddb ${this._dbName}` + `unable to connect to indexeddb ${this._dbName}` +

25
src/errors.js Normal file
View File

@@ -0,0 +1,25 @@
// can't just do InvalidStoreError extends Error
// because of http://babeljs.io/docs/usage/caveats/#classes
function InvalidStoreError(reason, value) {
const message = `Store is invalid because ${reason}, ` +
`please stop the client, delete all data and start the client again`;
const instance = Reflect.construct(Error, [message]);
Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
instance.reason = reason;
instance.value = value;
return instance;
}
InvalidStoreError.TOGGLED_LAZY_LOADING = "TOGGLED_LAZY_LOADING";
InvalidStoreError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true,
},
});
Reflect.setPrototypeOf(InvalidStoreError, Error);
module.exports.InvalidStoreError = InvalidStoreError;

View File

@@ -51,6 +51,17 @@ function Filter(userId, filterId) {
this.definition = {}; this.definition = {};
} }
Filter.LAZY_LOADING_MESSAGES_FILTER = {
lazy_load_members: true,
};
Filter.LAZY_LOADING_SYNC_FILTER = {
room: {
state: Filter.LAZY_LOADING_MESSAGES_FILTER,
},
};
/** /**
* Get the ID of this filter on your homeserver (if known) * Get the ID of this filter on your homeserver (if known)
* @return {?Number} The filter ID * @return {?Number} The filter ID

View File

@@ -34,6 +34,8 @@ module.exports.SyncAccumulator = require("./sync-accumulator");
module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi; module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi;
/** The {@link module:http-api.MatrixError|MatrixError} class. */ /** The {@link module:http-api.MatrixError|MatrixError} class. */
module.exports.MatrixError = require("./http-api").MatrixError; module.exports.MatrixError = require("./http-api").MatrixError;
/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */
module.exports.InvalidStoreError = require("./errors").InvalidStoreError;
/** The {@link module:client.MatrixClient|MatrixClient} class. */ /** The {@link module:client.MatrixClient|MatrixClient} class. */
module.exports.MatrixClient = require("./client").MatrixClient; module.exports.MatrixClient = require("./client").MatrixClient;
/** The {@link module:models/room|Room} class. */ /** The {@link module:models/room|Room} class. */

View File

@@ -168,49 +168,19 @@ EventTimelineSet.prototype.resetLiveTimeline = function(
// if timeline support is disabled, forget about the old timelines // if timeline support is disabled, forget about the old timelines
const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken;
let newTimeline; const oldTimeline = this._liveTimeline;
const newTimeline = resetAllTimelines ?
oldTimeline.forkLive(EventTimeline.FORWARDS) :
oldTimeline.fork(EventTimeline.FORWARDS);
if (resetAllTimelines) { if (resetAllTimelines) {
newTimeline = new EventTimeline(this);
this._timelines = [newTimeline]; this._timelines = [newTimeline];
this._eventIdToTimeline = {}; this._eventIdToTimeline = {};
} else { } else {
newTimeline = this.addTimeline(); this._timelines.push(newTimeline);
} }
const oldTimeline = this._liveTimeline; if (forwardPaginationToken) {
// Collect the state events from the old timeline
const evMap = oldTimeline.getState(EventTimeline.FORWARDS).events;
const events = [];
for (const evtype in evMap) {
if (!evMap.hasOwnProperty(evtype)) {
continue;
}
for (const stateKey in evMap[evtype]) {
if (!evMap[evtype].hasOwnProperty(stateKey)) {
continue;
}
events.push(evMap[evtype][stateKey]);
}
}
// Use those events to initialise the state of the new live timeline
newTimeline.initialiseState(events);
const freshEndState = newTimeline._endState;
// Now clobber the end state of the new live timeline with that from the
// previous live timeline. It will be identical except that we'll keep
// using the same RoomMember objects for the 'live' set of members with any
// listeners still attached
newTimeline._endState = oldTimeline._endState;
// If we're not resetting all timelines, we need to fix up the old live timeline
if (!resetAllTimelines) {
// Firstly, we just stole the old timeline's end state, so it needs a new one.
// Just swap them around and give it the one we just generated for the
// new live timeline.
oldTimeline._endState = freshEndState;
// Now set the forward pagination token on the old live timeline // Now set the forward pagination token on the old live timeline
// so it can be forward-paginated. // so it can be forward-paginated.
oldTimeline.setPaginationToken( oldTimeline.setPaginationToken(

View File

@@ -106,6 +106,50 @@ EventTimeline.prototype.initialiseState = function(stateEvents) {
this._endState.setStateEvents(stateEvents); this._endState.setStateEvents(stateEvents);
}; };
/**
* Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
* All attached listeners will keep receiving state updates from the new live timeline state.
* The end state of this timeline gets replaced with an independent copy of the current RoomState,
* and will need a new pagination token if it ever needs to paginate forwards.
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
EventTimeline.prototype.forkLive = function(direction) {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this._eventTimelineSet);
timeline._startState = forkState.clone();
// Now clobber the end state of the new live timeline with that from the
// previous live timeline. It will be identical except that we'll keep
// using the same RoomMember objects for the 'live' set of members with any
// listeners still attached
timeline._endState = forkState;
// Firstly, we just stole the current timeline's end state, so it needs a new one.
// Make an immutable copy of the state so back pagination will get the correct sentinels.
this._endState = forkState.clone();
return timeline;
};
/**
* Creates an independent timeline, inheriting the directional state from this timeline.
*
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
* of the timeline.
*
* @return {EventTimeline} the new timeline
*/
EventTimeline.prototype.fork = function(direction) {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this._eventTimelineSet);
timeline._startState = forkState.clone();
timeline._endState = forkState.clone();
return timeline;
};
/** /**
* Get the ID of the room for this timeline * Get the ID of the room for this timeline
* @return {string} room ID * @return {string} room ID

View File

@@ -404,6 +404,7 @@ utils.extend(module.exports.MatrixEvent.prototype, {
this._retryDecryption = false; this._retryDecryption = false;
let res; let res;
let err;
try { try {
if (!crypto) { if (!crypto) {
res = this._badEncryptedMessage("Encryption not enabled"); res = this._badEncryptedMessage("Encryption not enabled");
@@ -422,6 +423,8 @@ utils.extend(module.exports.MatrixEvent.prototype, {
return; return;
} }
err = e;
// see if we have a retry queued. // see if we have a retry queued.
// //
// NB: make sure to keep this check in the same tick of the // NB: make sure to keep this check in the same tick of the
@@ -467,6 +470,9 @@ utils.extend(module.exports.MatrixEvent.prototype, {
this._decryptionPromise = null; this._decryptionPromise = null;
this._retryDecryption = false; this._retryDecryption = false;
this._setClearData(res); this._setClearData(res);
this.emit("Event.decrypted", this, err);
return; return;
} }
}, },
@@ -503,7 +509,6 @@ utils.extend(module.exports.MatrixEvent.prototype, {
decryptionResult.claimedEd25519Key || null; decryptionResult.claimedEd25519Key || null;
this._forwardingCurve25519KeyChain = this._forwardingCurve25519KeyChain =
decryptionResult.forwardingCurve25519KeyChain || []; decryptionResult.forwardingCurve25519KeyChain || [];
this.emit("Event.decrypted", this);
}, },
/** /**
@@ -708,4 +713,7 @@ const _REDACT_KEEP_CONTENT_MAP = {
* *
* @param {module:models/event.MatrixEvent} event * @param {module:models/event.MatrixEvent} event
* The matrix event which has been decrypted * The matrix event which has been decrypted
* @param {module:crypto/algorithms/base.DecryptionError?} err
* The error that occured during decryption, or `undefined` if no
* error occured.
*/ */

View File

@@ -58,10 +58,27 @@ function RoomMember(roomId, userId) {
this.events = { this.events = {
member: null, member: null,
}; };
this._isOutOfBand = false;
this._updateModifiedTime(); this._updateModifiedTime();
} }
utils.inherits(RoomMember, EventEmitter); utils.inherits(RoomMember, EventEmitter);
/**
* Mark the member as coming from a channel that is not sync
*/
RoomMember.prototype.markOutOfBand = function() {
this._isOutOfBand = true;
};
/**
* @return {bool} does the member come from a channel that is not sync?
* This is used to store the member seperately
* from the sync state so it available across browser sessions.
*/
RoomMember.prototype.isOutOfBand = function() {
return this._isOutOfBand;
};
/** /**
* Update this room member's membership event. May fire "RoomMember.name" if * Update this room member's membership event. May fire "RoomMember.name" if
* this event updates this member's name. * this event updates this member's name.
@@ -75,13 +92,20 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) {
if (event.getType() !== "m.room.member") { if (event.getType() !== "m.room.member") {
return; return;
} }
this._isOutOfBand = false;
this.events.member = event; this.events.member = event;
const oldMembership = this.membership; const oldMembership = this.membership;
this.membership = event.getDirectionalContent().membership; this.membership = event.getDirectionalContent().membership;
const oldName = this.name; const oldName = this.name;
this.name = calculateDisplayName(this, event, roomState); this.name = calculateDisplayName(
this.userId,
event.getDirectionalContent().displayname,
roomState);
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
if (oldMembership !== this.membership) { if (oldMembership !== this.membership) {
this._updateModifiedTime(); this._updateModifiedTime();
@@ -177,6 +201,44 @@ RoomMember.prototype.getLastModifiedTime = function() {
return this._modified; return this._modified;
}; };
RoomMember.prototype.isKicked = function() {
return this.membership === "leave" &&
this.events.member.getSender() !== this.events.member.getStateKey();
};
/**
* If this member was invited with the is_direct flag set, return
* the user that invited this member
* @return {string} user id of the inviter
*/
RoomMember.prototype.getDMInviter = function() {
// when not available because that room state hasn't been loaded in,
// we don't really know, but more likely to not be a direct chat
if (this.events.member) {
// TODO: persist the is_direct flag on the member as more member events
// come in caused by displayName changes.
// the is_direct flag is set on the invite member event.
// This is copied on the prev_content section of the join member event
// when the invite is accepted.
const memberEvent = this.events.member;
let memberContent = memberEvent.getContent();
let inviteSender = memberEvent.getSender();
if (memberContent.membership === "join") {
memberContent = memberEvent.getPrevContent();
inviteSender = memberEvent.getUnsigned().prev_sender;
}
if (memberContent.membership === "invite" && memberContent.is_direct) {
return inviteSender;
}
}
};
/** /**
* Get the avatar URL for a room member. * Get the avatar URL for a room member.
* @param {string} baseUrl The base homeserver URL See * @param {string} baseUrl The base homeserver URL See
@@ -200,10 +262,12 @@ RoomMember.prototype.getAvatarUrl =
if (allowDefault === undefined) { if (allowDefault === undefined) {
allowDefault = true; allowDefault = true;
} }
if (!this.events.member && !allowDefault) {
const rawUrl = this.getMxcAvatarUrl();
if (!rawUrl && !allowDefault) {
return null; return null;
} }
const rawUrl = this.events.member ? this.events.member.getContent().avatar_url : null;
const httpUrl = ContentRepo.getHttpUriForMxc( const httpUrl = ContentRepo.getHttpUriForMxc(
baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks,
); );
@@ -216,12 +280,21 @@ RoomMember.prototype.getAvatarUrl =
} }
return null; return null;
}; };
/**
* get the mxc avatar url, either from a state event, or from a lazily loaded member
* @return {string} the mxc avatar url
*/
RoomMember.prototype.getMxcAvatarUrl = function() {
if(this.events.member) {
return this.events.member.getDirectionalContent().avatar_url;
} else if(this.user) {
return this.user.avatarUrl;
}
return null;
};
function calculateDisplayName(member, event, roomState) { function calculateDisplayName(selfUserId, displayName, roomState) {
const displayName = event.getDirectionalContent().displayname; if (!displayName || displayName === selfUserId) {
const selfUserId = member.userId;
if (!displayName) {
return selfUserId; return selfUserId;
} }
@@ -229,18 +302,23 @@ function calculateDisplayName(member, event, roomState) {
return displayName; return displayName;
} }
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
const strippedDisplayName = utils.removeHiddenChars(displayName);
if (!strippedDisplayName) {
return selfUserId;
}
// Check if the name contains something that look like a mxid // Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else // If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case // Show full mxid in this case
// Also show mxid if there are other people with the same displayname // Also show mxid if there are other people with the same displayname
// ignoring any zero width chars (unicode 200B-200D)
// if their displayname is made up of just zero width chars, show full mxid
let disambiguate = /@.+:.+/.test(displayName); let disambiguate = /@.+:.+/.test(displayName);
if (!disambiguate) { if (!disambiguate) {
const userIds = roomState.getUserIdsWithDisplayName(displayName); const userIds = roomState.getUserIdsWithDisplayName(strippedDisplayName);
const otherUsers = userIds.filter(function(u) { disambiguate = userIds.some((u) => u !== selfUserId);
return u !== selfUserId;
});
disambiguate = otherUsers.length > 0;
} }
if (disambiguate) { if (disambiguate) {

View File

@@ -22,6 +22,11 @@ const EventEmitter = require("events").EventEmitter;
const utils = require("../utils"); const utils = require("../utils");
const RoomMember = require("./room-member"); const RoomMember = require("./room-member");
// possible statuses for out-of-band member loading
const OOB_STATUS_NOTSTARTED = 1;
const OOB_STATUS_INPROGRESS = 2;
const OOB_STATUS_FINISHED = 3;
/** /**
* Construct room state. * Construct room state.
* *
@@ -46,13 +51,17 @@ const RoomMember = require("./room-member");
* @constructor * @constructor
* @param {?string} roomId Optional. The ID of the room which has this state. * @param {?string} roomId Optional. The ID of the room which has this state.
* If none is specified it just tracks paginationTokens, useful for notifTimelineSet * If none is specified it just tracks paginationTokens, useful for notifTimelineSet
* @param {?object} oobMemberFlags Optional. The state of loading out of bound members.
* As the timeline might get reset while they are loading, this state needs to be inherited
* and shared when the room state is cloned for the new timeline.
* This should only be passed from clone.
* @prop {Object.<string, RoomMember>} members The room member dictionary, keyed * @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
* on the user's ID. * on the user's ID.
* @prop {Object.<string, Object.<string, MatrixEvent>>} events The state * @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
* events dictionary, keyed on the event type and then the state_key value. * events dictionary, keyed on the event type and then the state_key value.
* @prop {string} paginationToken The pagination token for this state. * @prop {string} paginationToken The pagination token for this state.
*/ */
function RoomState(roomId) { function RoomState(roomId, oobMemberFlags = undefined) {
this.roomId = roomId; this.roomId = roomId;
this.members = { this.members = {
// userId: RoomMember // userId: RoomMember
@@ -70,6 +79,22 @@ function RoomState(roomId) {
this._userIdsToDisplayNames = {}; this._userIdsToDisplayNames = {};
this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite
this._joinedMemberCount = null; // cache of the number of joined members this._joinedMemberCount = null; // cache of the number of joined members
// joined members count from summary api
// once set, we know the server supports the summary api
// and we should only trust that
// we could also only trust that before OOB members
// are loaded but doesn't seem worth the hassle atm
this._summaryJoinedMemberCount = null;
// same for invited member count
this._invitedMemberCount = null;
this._summaryInvitedMemberCount = null;
if (!oobMemberFlags) {
oobMemberFlags = {
status: OOB_STATUS_NOTSTARTED,
};
}
this._oobMemberFlags = oobMemberFlags;
} }
utils.inherits(RoomState, EventEmitter); utils.inherits(RoomState, EventEmitter);
@@ -79,14 +104,48 @@ utils.inherits(RoomState, EventEmitter);
* @return {integer} The number of members in this room whose membership is 'join' * @return {integer} The number of members in this room whose membership is 'join'
*/ */
RoomState.prototype.getJoinedMemberCount = function() { RoomState.prototype.getJoinedMemberCount = function() {
if (this._summaryJoinedMemberCount !== null) {
return this._summaryJoinedMemberCount;
}
if (this._joinedMemberCount === null) { if (this._joinedMemberCount === null) {
this._joinedMemberCount = this.getMembers().filter((m) => { this._joinedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'join'; return m.membership === 'join' ? count + 1 : count;
}).length; }, 0);
} }
return this._joinedMemberCount; return this._joinedMemberCount;
}; };
/**
* Set the joined member count explicitly (like from summary part of the sync response)
* @param {number} count the amount of joined members
*/
RoomState.prototype.setJoinedMemberCount = function(count) {
this._summaryJoinedMemberCount = count;
};
/**
* Returns the number of invited members in this room
* @return {integer} The number of members in this room whose membership is 'invite'
*/
RoomState.prototype.getInvitedMemberCount = function() {
if (this._summaryInvitedMemberCount !== null) {
return this._summaryInvitedMemberCount;
}
if (this._invitedMemberCount === null) {
this._invitedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'invite' ? count + 1 : count;
}, 0);
}
return this._invitedMemberCount;
};
/**
* Set the amount of invited members in this room
* @param {number} count the amount of invited members
*/
RoomState.prototype.setInvitedMemberCount = function(count) {
this._summaryInvitedMemberCount = count;
};
/** /**
* Get all RoomMembers in this room. * Get all RoomMembers in this room.
* @return {Array<RoomMember>} A list of RoomMembers. * @return {Array<RoomMember>} A list of RoomMembers.
@@ -119,12 +178,9 @@ RoomState.prototype.getSentinelMember = function(userId) {
if (sentinel === undefined) { if (sentinel === undefined) {
sentinel = new RoomMember(this.roomId, userId); sentinel = new RoomMember(this.roomId, userId);
const membershipEvent = this.getStateEvents("m.room.member", userId); const member = this.members[userId];
if (!membershipEvent) return null; if (member) {
sentinel.setMembershipEvent(membershipEvent, this); sentinel.setMembershipEvent(member.events.member, this);
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
sentinel.setPowerLevelEvent(pwrLvlEvent);
} }
this._sentinels[userId] = sentinel; this._sentinels[userId] = sentinel;
} }
@@ -152,6 +208,67 @@ RoomState.prototype.getStateEvents = function(eventType, stateKey) {
return event ? event : null; return event ? event : null;
}; };
/**
* 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
*/
RoomState.prototype.clone = function() {
const copy = new RoomState(this.roomId, this._oobMemberFlags);
// Ugly hack: because setStateEvents will mark
// members as susperseding future out of bound members
// if loading is in progress (through _oobMemberFlags)
// since these are not new members, we're merely copying them
// set the status to not started
// after copying, we set back the status
const status = this._oobMemberFlags.status;
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
Object.values(this.events).forEach((eventsByStateKey) => {
const eventsForType = Object.values(eventsByStateKey);
copy.setStateEvents(eventsForType);
});
// Ugly hack: see above
this._oobMemberFlags.status = status;
if (this._summaryInvitedMemberCount !== null) {
copy.setInvitedMemberCount(this.getInvitedMemberCount());
}
if (this._summaryJoinedMemberCount !== null) {
copy.setJoinedMemberCount(this.getJoinedMemberCount());
}
// copy out of band flags if needed
if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) {
// copy markOutOfBand flags
this.getMembers().forEach((member) => {
if (member.isOutOfBand()) {
const copyMember = copy.getMember(member.userId);
copyMember.markOutOfBand();
}
});
}
return copy;
};
/**
* Add previously unknown state events.
* When lazy loading members while back-paginating,
* the relevant room state for the timeline chunk at the end
* of the chunk can be set with this method.
* @param {MatrixEvent[]} events state events to prepend
*/
RoomState.prototype.setUnknownStateEvents = function(events) {
const unknownStateEvents = events.filter((event) => {
return this.events[event.getType()] === undefined ||
this.events[event.getType()][event.getStateKey()] === undefined;
});
this.setStateEvents(unknownStateEvents);
};
/** /**
* Add an array of one or more state MatrixEvents, overwriting * Add an array of one or more state MatrixEvents, overwriting
* any existing state with the same {type, stateKey} tuple. Will fire * any existing state with the same {type, stateKey} tuple. Will fire
@@ -175,10 +292,7 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
return; return;
} }
if (self.events[event.getType()] === undefined) { self._setStateEvent(event);
self.events[event.getType()] = {};
}
self.events[event.getType()][event.getStateKey()] = event;
if (event.getType() === "m.room.member") { if (event.getType() === "m.room.member") {
_updateDisplayNameCache( _updateDisplayNameCache(
self, event.getStateKey(), event.getContent().displayname, self, event.getStateKey(), event.getContent().displayname,
@@ -216,24 +330,10 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
event.getPrevContent().displayname; event.getPrevContent().displayname;
} }
let member = self.members[userId]; const member = self._getOrCreateMember(userId, event);
if (!member) {
member = new RoomMember(event.getRoomId(), userId);
self.emit("RoomState.newMember", event, self, member);
}
member.setMembershipEvent(event, self); member.setMembershipEvent(event, self);
// this member may have a power level already, so set it.
const pwrLvlEvent = self.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated self._updateMember(member);
delete self._sentinels[userId];
self.members[userId] = member;
self._joinedMemberCount = null;
self.emit("RoomState.members", event, self, member); self.emit("RoomState.members", event, self, member);
} else if (event.getType() === "m.room.power_levels") { } else if (event.getType() === "m.room.power_levels") {
const members = utils.values(self.members); const members = utils.values(self.members);
@@ -248,6 +348,140 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
}); });
}; };
/**
* Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event.
* This method makes sure the member is added to the members dictionary
* before emitting, as this is done from setStateEvents and _setOutOfBandMember.
* @param {string} userId the id of the user to look up
* @param {MatrixEvent} event the membership event for the (new) member. Used to emit.
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @returns {RoomMember} the member, existing or newly created.
*/
RoomState.prototype._getOrCreateMember = function(userId, event) {
let member = this.members[userId];
if (!member) {
member = new RoomMember(this.roomId, userId);
// add member to members before emitting any events,
// as event handlers often lookup the member
this.members[userId] = member;
this.emit("RoomState.newMember", event, this, member);
}
return member;
};
RoomState.prototype._setStateEvent = function(event) {
if (this.events[event.getType()] === undefined) {
this.events[event.getType()] = {};
}
this.events[event.getType()][event.getStateKey()] = event;
};
RoomState.prototype._updateMember = function(member) {
// this member may have a power level already, so set it.
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated
delete this._sentinels[member.userId];
this.members[member.userId] = member;
this._joinedMemberCount = null;
this._invitedMemberCount = null;
};
/**
* Get the out-of-band members loading state, whether loading is needed or not.
* Note that loading might be in progress and hence isn't needed.
* @return {bool} whether or not the members of this room need to be loaded
*/
RoomState.prototype.needsOutOfBandMembers = function() {
return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED;
};
/**
* Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again
* through needsOutOfBandMembers
*/
RoomState.prototype.markOutOfBandMembersStarted = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_INPROGRESS;
};
/**
* Mark this room state as having failed to fetch out-of-band members
*/
RoomState.prototype.markOutOfBandMembersFailed = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
};
/**
* Clears the loaded out-of-band members
*/
RoomState.prototype.clearOutOfBandMembers = function() {
let count = 0;
Object.keys(this.members).forEach((userId) => {
const member = this.members[userId];
if (member.isOutOfBand()) {
++count;
delete this.members[userId];
}
});
console.log(`LL: RoomState removed ${count} members...`);
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
};
/**
* Sets the loaded out-of-band members.
* @param {MatrixEvent[]} stateEvents array of membership state events
*/
RoomState.prototype.setOutOfBandMembers = function(stateEvents) {
console.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
console.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`);
this._oobMemberFlags.status = OOB_STATUS_FINISHED;
stateEvents.forEach((e) => this._setOutOfBandMember(e));
};
/**
* Sets a single out of band member, used by both setOutOfBandMembers and clone
* @param {MatrixEvent} stateEvent membership state event
*/
RoomState.prototype._setOutOfBandMember = function(stateEvent) {
if (stateEvent.getType() !== 'm.room.member') {
return;
}
const userId = stateEvent.getStateKey();
const existingMember = this.getMember(userId);
// never replace members received as part of the sync
if (existingMember && !existingMember.isOutOfBand()) {
return;
}
const member = this._getOrCreateMember(userId, stateEvent);
member.setMembershipEvent(stateEvent, this);
// needed to know which members need to be stored seperately
// as they are not part of the sync accumulator
// this is cleared by setMembershipEvent so when it's updated through /sync
member.markOutOfBand();
_updateDisplayNameCache(this, member.userId, member.name);
this._setStateEvent(stateEvent);
this._updateMember(member);
this.emit("RoomState.members", stateEvent, this, member);
};
/** /**
* Set the current typing event for this room. * Set the current typing event for this room.
* @param {MatrixEvent} event The typing event * @param {MatrixEvent} event The typing event
@@ -401,11 +635,6 @@ RoomState.prototype.maySendStateEvent = function(stateEventType, userId) {
* according to the room's state. * according to the room's state.
*/ */
RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
const member = this.getMember(userId);
if (!member || member.membership == 'leave') {
return false;
}
const power_levels_event = this.getStateEvents('m.room.power_levels', ''); const power_levels_event = this.getStateEvents('m.room.power_levels', '');
let power_levels; let power_levels;
@@ -413,25 +642,34 @@ RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
let state_default = 0; let state_default = 0;
let events_default = 0; let events_default = 0;
let powerLevel = 0;
if (power_levels_event) { if (power_levels_event) {
power_levels = power_levels_event.getContent(); power_levels = power_levels_event.getContent();
events_levels = power_levels.events || {}; events_levels = power_levels.events || {};
if (utils.isNumber(power_levels.state_default)) { if (Number.isFinite(power_levels.state_default)) {
state_default = power_levels.state_default; state_default = power_levels.state_default;
} else { } else {
state_default = 50; state_default = 50;
} }
if (utils.isNumber(power_levels.events_default)) {
const userPowerLevel = power_levels.users && power_levels.users[userId];
if (Number.isFinite(userPowerLevel)) {
powerLevel = userPowerLevel;
} else if(Number.isFinite(power_levels.users_default)) {
powerLevel = power_levels.users_default;
}
if (Number.isFinite(power_levels.events_default)) {
events_default = power_levels.events_default; events_default = power_levels.events_default;
} }
} }
let required_level = state ? state_default : events_default; let required_level = state ? state_default : events_default;
if (utils.isNumber(events_levels[eventType])) { if (Number.isFinite(events_levels[eventType])) {
required_level = events_levels[eventType]; required_level = events_levels[eventType];
} }
return member.powerLevel >= required_level; return powerLevel >= required_level;
}; };
/** /**
@@ -494,22 +732,26 @@ function _updateDisplayNameCache(roomState, userId, displayName) {
// We clobber the user_id > name lookup but the name -> [user_id] lookup // We clobber the user_id > name lookup but the name -> [user_id] lookup
// means we need to remove that user ID from that array rather than nuking // means we need to remove that user ID from that array rather than nuking
// the lot. // the lot.
const existingUserIds = roomState._displayNameToUserIds[oldName] || []; const strippedOldName = utils.removeHiddenChars(oldName);
for (let i = 0; i < existingUserIds.length; i++) {
if (existingUserIds[i] === userId) { const existingUserIds = roomState._displayNameToUserIds[strippedOldName];
// remove this user ID from this array if (existingUserIds) {
existingUserIds.splice(i, 1); // remove this user ID from this array
i--; const filteredUserIDs = existingUserIds.filter((id) => id !== userId);
} roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs;
} }
roomState._displayNameToUserIds[oldName] = existingUserIds;
} }
roomState._userIdsToDisplayNames[userId] = displayName; roomState._userIdsToDisplayNames[userId] = displayName;
if (!roomState._displayNameToUserIds[displayName]) {
roomState._displayNameToUserIds[displayName] = []; const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
if (!roomState._displayNameToUserIds[strippedDisplayname]) {
roomState._displayNameToUserIds[strippedDisplayname] = [];
}
roomState._displayNameToUserIds[strippedDisplayname].push(userId);
} }
roomState._displayNameToUserIds[displayName].push(userId);
} }
/** /**
@@ -539,7 +781,8 @@ function _updateDisplayNameCache(roomState, userId, displayName) {
/** /**
* Fires whenever a member is added to the members dictionary. The RoomMember * Fires whenever a member is added to the members dictionary. The RoomMember
* will not be fully populated yet (e.g. no membership state). * will not be fully populated yet (e.g. no membership state) but will already
* be available in the members dictionary.
* @event module:client~MatrixClient#"RoomState.newMember" * @event module:client~MatrixClient#"RoomState.newMember"
* @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary * @param {RoomState} state The room state whose RoomState.members dictionary

View File

@@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -21,6 +22,7 @@ const EventEmitter = require("events").EventEmitter;
const EventStatus = require("./event").EventStatus; const EventStatus = require("./event").EventStatus;
const RoomSummary = require("./room-summary"); const RoomSummary = require("./room-summary");
const RoomMember = require("./room-member");
const MatrixEvent = require("./event").MatrixEvent; const MatrixEvent = require("./event").MatrixEvent;
const utils = require("../utils"); const utils = require("../utils");
const ContentRepo = require("../content-repo"); const ContentRepo = require("../content-repo");
@@ -29,6 +31,8 @@ const EventTimelineSet = require("./event-timeline-set");
import ReEmitter from '../ReEmitter'; import ReEmitter from '../ReEmitter';
const LATEST_ROOM_VERSION = '1';
function synthesizeReceipt(userId, event, receiptType) { function synthesizeReceipt(userId, event, receiptType) {
// console.log("synthesizing receipt for "+event.getId()); // console.log("synthesizing receipt for "+event.getId());
// This is really ugly because JS has no way to express an object literal // This is really ugly because JS has no way to express an object literal
@@ -68,6 +72,8 @@ function synthesizeReceipt(userId, event, receiptType) {
* @constructor * @constructor
* @alias module:models/room * @alias module:models/room
* @param {string} roomId Required. The ID of this room. * @param {string} roomId Required. The ID of this room.
* @param {MatrixClient} client Required. The client, used to lazy load members.
* @param {string} myUserId Required. The ID of the syncing user.
* @param {Object=} opts Configuration options * @param {Object=} opts Configuration options
* @param {*} opts.storageToken Optional. The token which a data store can use * @param {*} opts.storageToken Optional. The token which a data store can use
* to remember the state of the room. What this means is dependent on the store * to remember the state of the room. What this means is dependent on the store
@@ -102,7 +108,7 @@ function synthesizeReceipt(userId, event, receiptType) {
* @prop {*} storageToken A token which a data store can use to remember * @prop {*} storageToken A token which a data store can use to remember
* the state of the room. * the state of the room.
*/ */
function Room(roomId, opts) { function Room(roomId, client, myUserId, opts) {
opts = opts || {}; opts = opts || {};
opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
@@ -115,6 +121,7 @@ function Room(roomId, opts) {
); );
} }
this.myUserId = myUserId;
this.roomId = roomId; this.roomId = roomId;
this.name = roomId; this.name = roomId;
this.tags = { this.tags = {
@@ -171,9 +178,56 @@ function Room(roomId, opts) {
// read by megolm; boolean value - null indicates "use global value" // read by megolm; boolean value - null indicates "use global value"
this._blacklistUnverifiedDevices = null; this._blacklistUnverifiedDevices = null;
this._selfMembership = null;
this._summaryHeroes = null;
// awaited by getEncryptionTargetMembers while room members are loading
this._client = client;
if (!this._opts.lazyLoadMembers) {
this._membersPromise = Promise.resolve();
} else {
this._membersPromise = null;
}
} }
utils.inherits(Room, EventEmitter); utils.inherits(Room, EventEmitter);
/**
* Gets the version of the room
* @returns {string} The version of the room, or null if it could not be determined
*/
Room.prototype.getVersion = function() {
const createEvent = this.currentState.getStateEvents("m.room.create", "");
if (!createEvent) {
console.warn("Room " + this.room_id + " does not have an m.room.create event");
return '1';
}
const ver = createEvent.getContent()['room_version'];
if (ver === undefined) return '1';
return ver;
};
/**
* Determines whether this room needs to be upgraded to a new version
* @returns {string?} What version the room should be upgraded to, or null if
* the room does not require upgrading at this time.
*/
Room.prototype.shouldUpgradeToVersion = function() {
// This almost certainly won't be the way this actually works - this
// is essentially a stub method.
if (this.getVersion() === LATEST_ROOM_VERSION) return null;
return LATEST_ROOM_VERSION;
};
/**
* Determines whether the given user is permitted to perform a room upgrade
* @param {String} userId The ID of the user to test against
* @returns {bool} True if the given user is permitted to upgrade the room
*/
Room.prototype.userMayUpgradeRoom = function(userId) {
return this.currentState.maySendStateEvent("m.room.tombstone", userId);
};
/** /**
* Get the list of pending sent events for this room * Get the list of pending sent events for this room
* *
@@ -201,6 +255,232 @@ Room.prototype.getLiveTimeline = function() {
return this.getUnfilteredTimelineSet().getLiveTimeline(); return this.getUnfilteredTimelineSet().getLiveTimeline();
}; };
/**
* @param {string} myUserId the user id for the logged in member
* @return {string} the membership type (join | leave | invite) for the logged in user
*/
Room.prototype.getMyMembership = function() {
return this._selfMembership;
};
/**
* If this room is a DM we're invited to,
* try to find out who invited us
* @return {string} user id of the inviter
*/
Room.prototype.getDMInviter = function() {
if (this.myUserId) {
const me = this.getMember(this.myUserId);
if (me) {
return me.getDMInviter();
}
}
if (this._selfMembership === "invite") {
// fall back to summary information
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount == 2 && this._summaryHeroes.length) {
return this._summaryHeroes[0];
}
}
};
/**
* Assuming this room is a DM room, tries to guess with which user.
* @return {string} user id of the other member (could be syncing user)
*/
Room.prototype.guessDMUserId = function() {
const me = this.getMember(this.myUserId);
if (me) {
const inviterId = me.getDMInviter();
if (inviterId) {
return inviterId;
}
}
// remember, we're assuming this room is a DM,
// so returning the first member we find should be fine
const hasHeroes = Array.isArray(this._summaryHeroes) &&
this._summaryHeroes.length;
if (hasHeroes) {
return this._summaryHeroes[0];
}
const members = this.currentState.getMembers();
const anyMember = members.find((m) => m.userId !== this.myUserId);
if (anyMember) {
return anyMember.userId;
}
// it really seems like I'm the only user in the room
// so I probably created a room with just me in it
// and marked it as a DM. Ok then
return this.myUserId;
};
Room.prototype.getAvatarFallbackMember = function() {
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount > 2) {
return;
}
const hasHeroes = Array.isArray(this._summaryHeroes) &&
this._summaryHeroes.length;
if (hasHeroes) {
const availableMember = this._summaryHeroes.map((userId) => {
return this.getMember(userId);
}).find((member) => !!member);
if (availableMember) {
return availableMember;
}
}
const members = this.currentState.getMembers();
// could be different than memberCount
// as this includes left members
if (members.length <= 2) {
const availableMember = members.find((m) => {
return m.userId !== this.myUserId;
});
if (availableMember) {
return availableMember;
}
}
// if all else fails, try falling back to a user,
// and create a one-off member for it
if (hasHeroes) {
const availableUser = this._summaryHeroes.map((userId) => {
return this._client.getUser(userId);
}).find((user) => !!user);
if (availableUser) {
const member = new RoomMember(
this.roomId, availableUser.userId);
member.user = availableUser;
return member;
}
}
};
/**
* Sets the membership this room was received as during sync
* @param {string} membership join | leave | invite
*/
Room.prototype.updateMyMembership = function(membership) {
const prevMembership = this._selfMembership;
this._selfMembership = membership;
if (prevMembership !== membership) {
if (membership === "leave") {
this._cleanupAfterLeaving();
}
this.emit("Room.myMembership", this, membership, prevMembership);
}
};
Room.prototype._loadMembersFromServer = async function() {
const lastSyncToken = this._client.store.getSyncToken();
const queryString = utils.encodeParams({
not_membership: "leave",
at: lastSyncToken,
});
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{$roomId: this.roomId});
const http = this._client._http;
const response = await http.authedRequest(undefined, "GET", path);
return response.chunk;
};
Room.prototype._loadMembers = async function() {
// were the members loaded from the server?
let fromServer = false;
let rawMembersEvents =
await this._client.store.getOutOfBandMembers(this.roomId);
if (rawMembersEvents === null) {
fromServer = true;
rawMembersEvents = await this._loadMembersFromServer();
console.log(`LL: got ${rawMembersEvents.length} ` +
`members from server for room ${this.roomId}`);
}
const memberEvents = rawMembersEvents.map(this._client.getEventMapper());
return {memberEvents, fromServer};
};
/**
* Preloads the member list in case lazy loading
* of memberships is in use. Can be called multiple times,
* it will only preload once.
* @return {Promise} when preloading is done and
* accessing the members on the room will take
* all members in the room into account
*/
Room.prototype.loadMembersIfNeeded = function() {
if (this._membersPromise) {
return this._membersPromise;
}
// mark the state so that incoming messages while
// the request is in flight get marked as superseding
// the OOB members
this.currentState.markOutOfBandMembersStarted();
const inMemoryUpdate = this._loadMembers().then((result) => {
this.currentState.setOutOfBandMembers(result.memberEvents);
// now the members are loaded, start to track the e2e devices if needed
if (this._client.isRoomEncrypted(this.roomId)) {
this._client._crypto.trackRoomDevices(this.roomId);
}
return result.fromServer;
}).catch((err) => {
// allow retries on fail
this._membersPromise = null;
this.currentState.markOutOfBandMembersFailed();
throw err;
});
// update members in storage, but don't wait for it
inMemoryUpdate.then((fromServer) => {
if (fromServer) {
const oobMembers = this.currentState.getMembers()
.filter((m) => m.isOutOfBand())
.map((m) => m.events.member.event);
console.log(`LL: telling store to write ${oobMembers.length}`
+ ` members for room ${this.roomId}`);
const store = this._client.store;
return store.setOutOfBandMembers(this.roomId, oobMembers)
// swallow any IDB error as we don't want to fail
// because of this
.catch((err) => {
console.log("LL: storing OOB room members failed, oh well",
err);
});
}
}).catch((err) => {
// as this is not awaited anywhere,
// at least show the error in the console
console.error(err);
});
this._membersPromise = inMemoryUpdate;
return this._membersPromise;
};
/**
* Removes the lazily loaded members from storage if needed
*/
Room.prototype.clearLoadedMembersIfNeeded = async function() {
if (this._opts.lazyLoadMembers && this._membersPromise) {
await this.loadMembersIfNeeded();
await this._client.store.clearOutOfBandMembers(this.roomId);
this.currentState.clearOutOfBandMembers();
this._membersPromise = null;
}
};
/**
* called when sync receives this room in the leave section
* to do cleanup after leaving a room. Possibly called multiple times.
*/
Room.prototype._cleanupAfterLeaving = function() {
this.clearLoadedMembersIfNeeded().catch((err) => {
console.error(`error after clearing loaded members from ` +
`room ${this.roomId} after leaving`);
console.dir(err);
});
};
/** /**
* Reset the live timeline of all timelineSets, and start new ones. * Reset the live timeline of all timelineSets, and start new ones.
@@ -306,6 +586,26 @@ Room.prototype.setUnreadNotificationCount = function(type, count) {
this._notificationCounts[type] = count; this._notificationCounts[type] = count;
}; };
Room.prototype.setSummary = function(summary) {
const heroes = summary["m.heroes"];
const joinedCount = summary["m.joined_member_count"];
const invitedCount = summary["m.invited_member_count"];
if (Number.isInteger(joinedCount)) {
this.currentState.setJoinedMemberCount(joinedCount);
}
if (Number.isInteger(invitedCount)) {
this.currentState.setInvitedMemberCount(invitedCount);
}
if (Array.isArray(heroes)) {
// be cautious about trusting server values,
// and make sure heroes doesn't contain our own id
// just to be sure
this._summaryHeroes = heroes.filter((userId) => {
return userId !== this.myUserId;
});
}
};
/** /**
* Whether to send encrypted messages to devices within this room. * Whether to send encrypted messages to devices within this room.
* @param {Boolean} value true to blacklist unverified devices, null * @param {Boolean} value true to blacklist unverified devices, null
@@ -430,11 +730,7 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
* @return {RoomMember} The member or <code>null</code>. * @return {RoomMember} The member or <code>null</code>.
*/ */
Room.prototype.getMember = function(userId) { Room.prototype.getMember = function(userId) {
const member = this.currentState.members[userId]; return this.currentState.getMember(userId);
if (!member) {
return null;
}
return member;
}; };
/** /**
@@ -445,6 +741,33 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
return this.getMembersWithMembership("join"); return this.getMembersWithMembership("join");
}; };
/**
* Returns the number of joined members in this room
* This method caches the result.
* This is a wrapper around the method of the same name in roomState, returning
* its result for the room's current state.
* @return {integer} The number of members in this room whose membership is 'join'
*/
Room.prototype.getJoinedMemberCount = function() {
return this.currentState.getJoinedMemberCount();
};
/**
* Returns the number of invited members in this room
* @return {integer} The number of members in this room whose membership is 'invite'
*/
Room.prototype.getInvitedMemberCount = function() {
return this.currentState.getInvitedMemberCount();
};
/**
* Returns the number of invited + joined members in this room
* @return {integer} The number of members in this room whose membership is 'invite' or 'join'
*/
Room.prototype.getInvitedAndJoinedMemberCount = function() {
return this.getInvitedMemberCount() + this.getJoinedMemberCount();
};
/** /**
* Get a list of members with given membership state. * Get a list of members with given membership state.
* @param {string} membership The membership state. * @param {string} membership The membership state.
@@ -456,6 +779,29 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
}); });
}; };
/**
* Get a list of members we should be encrypting for in this room
* @return {Promise<RoomMember[]>} A list of members who
* we should encrypt messages for in this room.
*/
Room.prototype.getEncryptionTargetMembers = async function() {
await this.loadMembersIfNeeded();
let members = this.getMembersWithMembership("join");
if (this.shouldEncryptForInvitedMembers()) {
members = members.concat(this.getMembersWithMembership("invite"));
}
return members;
};
/**
* Determine whether we should encrypt messages for invited users in this room
* @return {boolean} if we should encrypt messages for invited users
*/
Room.prototype.shouldEncryptForInvitedMembers = function() {
const ev = this.currentState.getStateEvents("m.room.history_visibility", "");
return (ev && ev.getContent() && ev.getContent().history_visibility !== "joined");
};
/** /**
* Get the default room name (i.e. what a given user would see if the * Get the default room name (i.e. what a given user would see if the
* room had no m.room.name) * room had no m.room.name)
@@ -909,15 +1255,14 @@ Room.prototype.removeEvent = function(eventId) {
* Recalculate various aspects of the room, including the room name and * Recalculate various aspects of the room, including the room name and
* room summary. Call this any time the room's current state is modified. * room summary. Call this any time the room's current state is modified.
* May fire "Room.name" if the room name is updated. * May fire "Room.name" if the room name is updated.
* @param {string} userId The client's user ID.
* @fires module:client~MatrixClient#event:"Room.name" * @fires module:client~MatrixClient#event:"Room.name"
*/ */
Room.prototype.recalculate = function(userId) { Room.prototype.recalculate = function() {
// set fake stripped state events if this is an invite room so logic remains // set fake stripped state events if this is an invite room so logic remains
// consistent elsewhere. // consistent elsewhere.
const self = this; const self = this;
const membershipEvent = this.currentState.getStateEvents( const membershipEvent = this.currentState.getStateEvents(
"m.room.member", userId, "m.room.member", this.myUserId,
); );
if (membershipEvent && membershipEvent.getContent().membership === "invite") { if (membershipEvent && membershipEvent.getContent().membership === "invite") {
const strippedStateEvents = membershipEvent.event.invite_room_state || []; const strippedStateEvents = membershipEvent.event.invite_room_state || [];
@@ -933,14 +1278,14 @@ Room.prototype.recalculate = function(userId) {
content: strippedEvent.content, content: strippedEvent.content,
event_id: "$fake" + Date.now(), event_id: "$fake" + Date.now(),
room_id: self.roomId, room_id: self.roomId,
user_id: userId, // technically a lie user_id: self.myUserId, // technically a lie
})]); })]);
} }
}); });
} }
const oldName = this.name; const oldName = this.name;
this.name = calculateRoomName(this, userId); this.name = calculateRoomName(this, this.myUserId);
this.summary = new RoomSummary(this.roomId, { this.summary = new RoomSummary(this.roomId, {
title: this.name, title: this.name,
}); });
@@ -950,7 +1295,6 @@ Room.prototype.recalculate = function(userId) {
} }
}; };
/** /**
* Get a list of user IDs who have <b>read up to</b> the given event. * Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for. * @param {MatrixEvent} event the event to get read receipts for.
@@ -1122,7 +1466,7 @@ Room.prototype.addTags = function(event) {
// } // }
// XXX: do we need to deep copy here? // XXX: do we need to deep copy here?
this.tags = event.getContent().tags; this.tags = event.getContent().tags || {};
// XXX: we could do a deep-comparison to see if the tags have really // XXX: we could do a deep-comparison to see if the tags have really
// changed - but do we want to bother? // changed - but do we want to bother?
@@ -1153,6 +1497,17 @@ Room.prototype.getAccountData = function(type) {
return this.accountData[type]; return this.accountData[type];
}; };
/**
* Returns wheter the syncing user has permission to send a message in the room
* @return {boolean} true if the user should be permitted to send
* message events into the room.
*/
Room.prototype.maySendMessage = function() {
return this.getMyMembership() === 'join' &&
this.currentState.maySendEvent('m.room.message', this.myUserId);
};
/** /**
* This is an internal method. Calculates the name of the room from the current * This is an internal method. Calculates the name of the room from the current
* room state. * room state.
@@ -1186,87 +1541,83 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) {
return alias; return alias;
} }
// get members that are NOT ourselves and are actually in the room. const joinedMemberCount = room.currentState.getJoinedMemberCount();
const otherMembers = utils.filter(room.currentState.getMembers(), function(m) { const invitedMemberCount = room.currentState.getInvitedMemberCount();
return ( // -1 because these numbers include the syncing user
m.userId !== userId && m.membership !== "leave" && m.membership !== "ban" const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1;
);
});
const allMembers = utils.filter(room.currentState.getMembers(), function(m) {
return (m.membership !== "leave");
});
const myMemberEventArray = utils.filter(room.currentState.getMembers(), function(m) {
return (m.userId == userId);
});
const myMemberEvent = (
(myMemberEventArray.length && myMemberEventArray[0].events) ?
myMemberEventArray[0].events.member.event : undefined
);
// TODO: Localisation // get members that are NOT ourselves and are actually in the room.
if (myMemberEvent && myMemberEvent.content.membership == "invite") { let otherNames = null;
if (room.currentState.getMember(myMemberEvent.sender)) { if (room._summaryHeroes) {
// extract who invited us to the room // if we have a summary, the member state events
return room.currentState.getMember( // should be in the room state
myMemberEvent.sender, otherNames = room._summaryHeroes.map((userId) => {
).name; const member = room.getMember(userId);
} else if (allMembers[0].events.member) { return member ? member.name : userId;
// use the sender field from the invite event, although this only });
// gets us the mxid } else {
return myMemberEvent.sender; let otherMembers = room.currentState.getMembers().filter((m) => {
} else { return m.userId !== userId &&
return "Room Invite"; (m.membership === "invite" || m.membership === "join");
} });
// make sure members have stable order
otherMembers.sort((a, b) => a.userId.localeCompare(b.userId));
// only 5 first members, immitate _summaryHeroes
otherMembers = otherMembers.slice(0, 5);
otherNames = otherMembers.map((m) => m.name);
} }
if (inviteJoinCount) {
return memberNamesToRoomName(otherNames, inviteJoinCount);
}
if (otherMembers.length === 0) { const myMembership = room.getMyMembership();
const leftMembers = utils.filter(room.currentState.getMembers(), function(m) { // if I have created a room and invited people throuh
return m.userId !== userId && m.membership === "leave"; // 3rd party invites
}); if (myMembership == 'join') {
if (allMembers.length === 1) { const thirdPartyInvites =
// self-chat, peeked room with 1 participant, room.currentState.getStateEvents("m.room.third_party_invite");
// or inbound invite, or outbound 3PID invite.
if (allMembers[0].userId === userId) { if (thirdPartyInvites && thirdPartyInvites.length) {
const thirdPartyInvites = const thirdPartyNames = thirdPartyInvites.map((i) => {
room.currentState.getStateEvents("m.room.third_party_invite"); return i.getContent().display_name;
if (thirdPartyInvites && thirdPartyInvites.length > 0) { });
let name = "Inviting " +
thirdPartyInvites[0].getContent().display_name; return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`;
if (thirdPartyInvites.length > 1) {
if (thirdPartyInvites.length == 2) {
name += " and " +
thirdPartyInvites[1].getContent().display_name;
} else {
name += " and " +
thirdPartyInvites.length + " others";
}
}
return name;
} else if (leftMembers.length === 1) {
// if it was a chat with one person who's now left, it's still
// notionally a chat with them
return leftMembers[0].name;
} else {
return "Empty room";
}
} else {
return allMembers[0].name;
}
} else {
// there really isn't anyone in this room...
return "Empty room";
} }
} else if (otherMembers.length === 1) { }
return otherMembers[0].name; // let's try to figure out who was here before
} else if (otherMembers.length === 2) { let leftNames = otherNames;
return ( // if we didn't have heroes, try finding them in the room state
otherMembers[0].name + " and " + otherMembers[1].name if(!leftNames.length) {
); leftNames = room.currentState.getMembers().filter((m) => {
return m.userId !== userId &&
m.membership !== "invite" &&
m.membership !== "join";
}).map((m) => m.name);
}
if(leftNames.length) {
return `Empty room (was ${memberNamesToRoomName(leftNames)})`;
} else { } else {
return ( return "Empty room";
otherMembers[0].name + " and " + (otherMembers.length - 1) + " others" }
); }
function memberNamesToRoomName(names, count = (names.length + 1)) {
const countWithoutMe = count - 1;
if (!names.length) {
return count <= 1 ? "Empty room" : null;
} else if (names.length === 1 && countWithoutMe <= 1) {
return names[0];
} else if (names.length === 2 && countWithoutMe <= 2) {
return `${names[0]} and ${names[1]}`;
} else {
const plural = countWithoutMe > 1;
if (plural) {
return `${names[0]} and ${countWithoutMe} others`;
} else {
return `${names[0]} and 1 other`;
}
} }
} }

View File

@@ -19,7 +19,7 @@ import Promise from 'bluebird';
import SyncAccumulator from "../sync-accumulator"; import SyncAccumulator from "../sync-accumulator";
import utils from "../utils"; import utils from "../utils";
const VERSION = 1; const VERSION = 3;
function createDatabase(db) { function createDatabase(db) {
// Make user store, clobber based on user ID. (userId property of User objects) // Make user store, clobber based on user ID. (userId property of User objects)
@@ -33,6 +33,20 @@ function createDatabase(db) {
db.createObjectStore("sync", { keyPath: ["clobber"] }); db.createObjectStore("sync", { keyPath: ["clobber"] });
} }
function upgradeSchemaV2(db) {
const oobMembersStore = db.createObjectStore(
"oob_membership_events", {
keyPath: ["room_id", "state_key"],
});
oobMembersStore.createIndex("room", "room_id");
}
function upgradeSchemaV3(db) {
db.createObjectStore("client_options",
{ keyPath: ["clobber"]});
}
/** /**
* Helper method to collect results from a Cursor and promiseify it. * Helper method to collect results from a Cursor and promiseify it.
* @param {ObjectStore|Index} store The store to perform openCursor on. * @param {ObjectStore|Index} store The store to perform openCursor on.
@@ -63,28 +77,39 @@ function selectQuery(store, keyRange, resultMapper) {
}); });
} }
function promiseifyTxn(txn) { function txnAsPromise(txn) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
txn.oncomplete = function(event) { txn.oncomplete = function(event) {
resolve(event); resolve(event);
}; };
txn.onerror = function(event) { txn.onerror = function(event) {
reject(event); reject(event.target.error);
}; };
}); });
} }
function promiseifyRequest(req) { function reqAsEventPromise(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
req.onsuccess = function(event) { req.onsuccess = function(event) {
resolve(event); resolve(event);
}; };
req.onerror = function(event) { req.onerror = function(event) {
reject(event); reject(event.target.error);
}; };
}); });
} }
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req);
req.onerror = (err) => reject(err);
});
}
function reqAsCursorPromise(req) {
return reqAsEventPromise(req).then((event) => event.target.result);
}
/** /**
* Does the actual reading from and writing to the indexeddb * Does the actual reading from and writing to the indexeddb
* *
@@ -104,6 +129,7 @@ const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend(
this.db = null; this.db = null;
this._disconnected = true; this._disconnected = true;
this._syncAccumulator = new SyncAccumulator(); this._syncAccumulator = new SyncAccumulator();
this._isNewlyCreated = false;
}; };
@@ -134,8 +160,15 @@ LocalIndexedDBStoreBackend.prototype = {
`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`,
); );
if (oldVersion < 1) { // The database did not previously exist. if (oldVersion < 1) { // The database did not previously exist.
this._isNewlyCreated = true;
createDatabase(db); createDatabase(db);
} }
if (oldVersion < 2) {
upgradeSchemaV2(db);
}
if (oldVersion < 3) {
upgradeSchemaV3(db);
}
// Expand as needed. // Expand as needed.
}; };
@@ -148,7 +181,7 @@ LocalIndexedDBStoreBackend.prototype = {
console.log( console.log(
`LocalIndexedDBStoreBackend.connect: awaiting connection...`, `LocalIndexedDBStoreBackend.connect: awaiting connection...`,
); );
return promiseifyRequest(req).then((ev) => { return reqAsEventPromise(req).then((ev) => {
console.log( console.log(
`LocalIndexedDBStoreBackend.connect: connected`, `LocalIndexedDBStoreBackend.connect: connected`,
); );
@@ -163,6 +196,10 @@ LocalIndexedDBStoreBackend.prototype = {
return this._init(); return this._init();
}); });
}, },
/** @return {bool} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return Promise.resolve(this._isNewlyCreated);
},
/** /**
* Having connected, load initial data from the database and prepare for use * Having connected, load initial data from the database and prepare for use
@@ -187,6 +224,124 @@ LocalIndexedDBStoreBackend.prototype = {
}); });
}, },
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {Promise<event[]>} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
getOutOfBandMembers: function(roomId) {
return new Promise((resolve, reject) =>{
const tx = this.db.transaction(["oob_membership_events"], "readonly");
const store = tx.objectStore("oob_membership_events");
const roomIndex = store.index("room");
const range = IDBKeyRange.only(roomId);
const request = roomIndex.openCursor(range);
const membershipEvents = [];
// did we encounter the oob_written marker object
// amongst the results? That means OOB member
// loading already happened for this room
// but there were no members to persist as they
// were all known already
let oobWritten = false;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
// Unknown room
if (!membershipEvents.length && !oobWritten) {
return resolve(null);
}
return resolve(membershipEvents);
}
const record = cursor.value;
if (record.oob_written) {
oobWritten = true;
} else {
membershipEvents.push(record);
}
cursor.continue();
};
request.onerror = (err) => {
reject(err);
};
}).then((events) => {
console.log(`LL: got ${events && events.length}` +
` membershipEvents from storage for room ${roomId} ...`);
return events;
});
},
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
*/
setOutOfBandMembers: async function(roomId, membershipEvents) {
console.log(`LL: backend about to store ${membershipEvents.length}` +
` members for ${roomId}`);
const tx = this.db.transaction(["oob_membership_events"], "readwrite");
const store = tx.objectStore("oob_membership_events");
membershipEvents.forEach((e) => {
store.put(e);
});
// aside from all the events, we also write a marker object to the store
// to mark the fact that OOB members have been written for this room.
// It's possible that 0 members need to be written as all where previously know
// but we still need to know whether to return null or [] from getOutOfBandMembers
// where null means out of band members haven't been stored yet for this room
const markerObject = {
room_id: roomId,
oob_written: true,
state_key: 0,
};
store.put(markerObject);
await txnAsPromise(tx);
console.log(`LL: backend done storing for ${roomId}!`);
},
clearOutOfBandMembers: async function(roomId) {
// the approach to delete all members for a room
// is to get the min and max state key from the index
// for that room, and then delete between those
// keys in the store.
// this should be way faster than deleting every member
// individually for a large room.
const readTx = this.db.transaction(
["oob_membership_events"],
"readonly");
const store = readTx.objectStore("oob_membership_events");
const roomIndex = store.index("room");
const roomRange = IDBKeyRange.only(roomId);
const minStateKeyProm = reqAsCursorPromise(
roomIndex.openKeyCursor(roomRange, "next"),
).then((cursor) => cursor && cursor.primaryKey[1]);
const maxStateKeyProm = reqAsCursorPromise(
roomIndex.openKeyCursor(roomRange, "prev"),
).then((cursor) => cursor && cursor.primaryKey[1]);
const [minStateKey, maxStateKey] = await Promise.all(
[minStateKeyProm, maxStateKeyProm]);
const writeTx = this.db.transaction(
["oob_membership_events"],
"readwrite");
const writeStore = writeTx.objectStore("oob_membership_events");
const membersKeyRange = IDBKeyRange.bound(
[roomId, minStateKey],
[roomId, maxStateKey],
);
console.log(`LL: Deleting all users + marker in storage for ` +
`room ${roomId}, with key range:`,
[roomId, minStateKey], [roomId, maxStateKey]);
await reqAsPromise(writeStore.delete(membersKeyRange));
},
/** /**
* Clear the entire database. This should be used when logging out of a client * Clear the entire database. This should be used when logging out of a client
* to prevent mixing data between accounts. * to prevent mixing data between accounts.
@@ -284,7 +439,7 @@ LocalIndexedDBStoreBackend.prototype = {
roomsData: roomsData, roomsData: roomsData,
groupsData: groupsData, groupsData: groupsData,
}); // put == UPSERT }); // put == UPSERT
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -301,7 +456,7 @@ LocalIndexedDBStoreBackend.prototype = {
for (let i = 0; i < accountData.length; i++) { for (let i = 0; i < accountData.length; i++) {
store.put(accountData[i]); // put == UPSERT store.put(accountData[i]); // put == UPSERT
} }
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -323,7 +478,7 @@ LocalIndexedDBStoreBackend.prototype = {
event: tuple[1], event: tuple[1],
}); // put == UPSERT }); // put == UPSERT
} }
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -389,6 +544,28 @@ LocalIndexedDBStoreBackend.prototype = {
}); });
}); });
}, },
getClientOptions: function() {
return Promise.resolve().then(() => {
const txn = this.db.transaction(["client_options"], "readonly");
const store = txn.objectStore("client_options");
return selectQuery(store, undefined, (cursor) => {
if (cursor.value && cursor.value && cursor.value.options) {
return cursor.value.options;
}
}).then((results) => results[0]);
});
},
storeClientOptions: async function(options) {
const txn = this.db.transaction(["client_options"], "readwrite");
const store = txn.objectStore("client_options");
store.put({
clobber: "-", // constant key so will always clobber
options: options,
}); // put == UPSERT
await txnAsPromise(txn);
},
}; };
export default LocalIndexedDBStoreBackend; export default LocalIndexedDBStoreBackend;

View File

@@ -65,7 +65,10 @@ RemoteIndexedDBStoreBackend.prototype = {
clearDatabase: function() { clearDatabase: function() {
return this._ensureStarted().then(() => this._doCmd('clearDatabase')); return this._ensureStarted().then(() => this._doCmd('clearDatabase'));
}, },
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return this._doCmd('isNewlyCreated');
},
/** /**
* @return {Promise} Resolves with a sync response to restore the * @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there * client state to where it was at the last save, or null if there
@@ -87,6 +90,40 @@ RemoteIndexedDBStoreBackend.prototype = {
return this._doCmd('syncToDatabase', [users]); return this._doCmd('syncToDatabase', [users]);
}, },
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
getOutOfBandMembers: function(roomId) {
return this._doCmd('getOutOfBandMembers', [roomId]);
},
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
setOutOfBandMembers: function(roomId, membershipEvents) {
return this._doCmd('setOutOfBandMembers', [roomId, membershipEvents]);
},
clearOutOfBandMembers: function(roomId) {
return this._doCmd('clearOutOfBandMembers', [roomId]);
},
getClientOptions: function() {
return this._doCmd('getClientOptions');
},
storeClientOptions: function(options) {
return this._doCmd('storeClientOptions', [options]);
},
/** /**
* Load all user presence events from the database. This is not cached. * Load all user presence events from the database. This is not cached.
@@ -147,7 +184,9 @@ RemoteIndexedDBStoreBackend.prototype = {
if (msg.command == 'cmd_success') { if (msg.command == 'cmd_success') {
def.resolve(msg.result); def.resolve(msg.result);
} else { } else {
def.reject(msg.error); const error = new Error(msg.error.message);
error.name = msg.error.name;
def.reject(error);
} }
} else { } else {
console.warn("Unrecognised message from worker: " + msg); console.warn("Unrecognised message from worker: " + msg);

View File

@@ -67,6 +67,9 @@ class IndexedDBStoreWorker {
case 'connect': case 'connect':
prom = this.backend.connect(); prom = this.backend.connect();
break; break;
case 'isNewlyCreated':
prom = this.backend.isNewlyCreated();
break;
case 'clearDatabase': case 'clearDatabase':
prom = this.backend.clearDatabase().then((result) => { prom = this.backend.clearDatabase().then((result) => {
// This returns special classes which can't be cloned // This returns special classes which can't be cloned
@@ -92,10 +95,25 @@ class IndexedDBStoreWorker {
case 'getNextBatchToken': case 'getNextBatchToken':
prom = this.backend.getNextBatchToken(); prom = this.backend.getNextBatchToken();
break; break;
case 'getOutOfBandMembers':
prom = this.backend.getOutOfBandMembers(msg.args[0]);
break;
case 'clearOutOfBandMembers':
prom = this.backend.clearOutOfBandMembers(msg.args[0]);
break;
case 'setOutOfBandMembers':
prom = this.backend.setOutOfBandMembers(msg.args[0], msg.args[1]);
break;
case 'getClientOptions':
prom = this.backend.getClientOptions();
break;
case 'storeClientOptions':
prom = this.backend.storeClientOptions(msg.args[0]);
break;
} }
if (prom === undefined) { if (prom === undefined) {
postMessage({ this.postMessage({
command: 'cmd_fail', command: 'cmd_fail',
seq: msg.seq, seq: msg.seq,
// Can't be an Error because they're not structured cloneable // Can't be an Error because they're not structured cloneable
@@ -117,7 +135,10 @@ class IndexedDBStoreWorker {
command: 'cmd_fail', command: 'cmd_fail',
seq: msg.seq, seq: msg.seq,
// Just send a string because Error objects aren't cloneable // Just send a string because Error objects aren't cloneable
error: "Error running command", error: {
message: err.message,
name: err.name,
},
}); });
}); });
} }

View File

@@ -146,6 +146,11 @@ IndexedDBStore.prototype.getSavedSync = function() {
return this.backend.getSavedSync(); return this.backend.getSavedSync();
}; };
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
IndexedDBStore.prototype.isNewlyCreated = function() {
return this.backend.isNewlyCreated();
};
/** /**
* @return {Promise} If there is a saved sync, the nextBatch token * @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null. * for this sync, otherwise null.
@@ -219,4 +224,39 @@ IndexedDBStore.prototype.setSyncData = function(syncData) {
return this.backend.setSyncData(syncData); return this.backend.setSyncData(syncData);
}; };
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
IndexedDBStore.prototype.getOutOfBandMembers = function(roomId) {
return this.backend.getOutOfBandMembers(roomId);
};
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
IndexedDBStore.prototype.setOutOfBandMembers = function(roomId, membershipEvents) {
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
};
IndexedDBStore.prototype.clearOutOfBandMembers = function(roomId) {
return this.backend.clearOutOfBandMembers(roomId);
};
IndexedDBStore.prototype.getClientOptions = function() {
return this.backend.getClientOptions();
};
IndexedDBStore.prototype.storeClientOptions = function(options) {
return this.backend.storeClientOptions(options);
};
module.exports.IndexedDBStore = IndexedDBStore; module.exports.IndexedDBStore = IndexedDBStore;

View File

@@ -52,6 +52,10 @@ module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) {
// type : content // type : content
}; };
this.localStorage = opts.localStorage; this.localStorage = opts.localStorage;
this._oobMembers = {
// roomId: [member events]
};
this._clientOptions = {};
}; };
module.exports.MatrixInMemoryStore.prototype = { module.exports.MatrixInMemoryStore.prototype = {
@@ -64,6 +68,10 @@ module.exports.MatrixInMemoryStore.prototype = {
return this.syncToken; return this.syncToken;
}, },
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return Promise.resolve(true);
},
/** /**
* Set the token to stream from. * Set the token to stream from.
@@ -377,4 +385,35 @@ module.exports.MatrixInMemoryStore.prototype = {
}; };
return Promise.resolve(); return Promise.resolve();
}, },
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
getOutOfBandMembers: function(roomId) {
return Promise.resolve(this._oobMembers[roomId] || null);
},
/**
* Stores the out-of-band membership events for this room. Note that
* it still makes sense to store an empty array as the OOB status for the room is
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
setOutOfBandMembers: function(roomId, membershipEvents) {
this._oobMembers[roomId] = membershipEvents;
return Promise.resolve();
},
getClientOptions: function() {
return Promise.resolve(this._clientOptions);
},
storeClientOptions: function(options) {
this._clientOptions = Object.assign({}, options);
return Promise.resolve();
},
}; };

View File

@@ -32,6 +32,11 @@ function StubStore() {
StubStore.prototype = { StubStore.prototype = {
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return Promise.resolve(true);
},
/** /**
* Get the sync token. * Get the sync token.
* @return {string} * @return {string}
@@ -264,6 +269,26 @@ StubStore.prototype = {
deleteAllData: function() { deleteAllData: function() {
return Promise.resolve(); return Promise.resolve();
}, },
getOutOfBandMembers: function() {
return Promise.resolve(null);
},
setOutOfBandMembers: function() {
return Promise.resolve();
},
clearOutOfBandMembers: function() {
return Promise.resolve();
},
getClientOptions: function() {
return Promise.resolve();
},
storeClientOptions: function() {
return Promise.resolve();
},
}; };
/** Stub Store class. */ /** Stub Store class. */

View File

@@ -63,6 +63,11 @@ class SyncAccumulator {
// { event: $event, token: null|token }, // { event: $event, token: null|token },
// ... // ...
// ], // ],
// _summary: {
// m.heroes: [ $user_id ],
// m.joined_member_count: $count,
// m.invited_member_count: $count
// },
// _accountData: { $event_type: json }, // _accountData: { $event_type: json },
// _unreadNotifications: { ... unread_notifications JSON ... }, // _unreadNotifications: { ... unread_notifications JSON ... },
// _readReceipts: { $user_id: { data: $json, eventId: $event_id }} // _readReceipts: { $user_id: { data: $json, eventId: $event_id }}
@@ -242,6 +247,7 @@ class SyncAccumulator {
_timeline: [], _timeline: [],
_accountData: Object.create(null), _accountData: Object.create(null),
_unreadNotifications: {}, _unreadNotifications: {},
_summary: {},
_readReceipts: {}, _readReceipts: {},
}; };
} }
@@ -258,6 +264,17 @@ class SyncAccumulator {
if (data.unread_notifications) { if (data.unread_notifications) {
currentData._unreadNotifications = data.unread_notifications; currentData._unreadNotifications = data.unread_notifications;
} }
if (data.summary) {
const HEROES_KEY = "m.heroes";
const INVITED_COUNT_KEY = "m.invited_member_count";
const JOINED_COUNT_KEY = "m.joined_member_count";
const acc = currentData._summary;
const sum = data.summary;
acc[HEROES_KEY] = sum[HEROES_KEY] || acc[HEROES_KEY];
acc[JOINED_COUNT_KEY] = sum[JOINED_COUNT_KEY] || acc[JOINED_COUNT_KEY];
acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
}
if (data.ephemeral && data.ephemeral.events) { if (data.ephemeral && data.ephemeral.events) {
data.ephemeral.events.forEach((e) => { data.ephemeral.events.forEach((e) => {
@@ -428,6 +445,7 @@ class SyncAccumulator {
prev_batch: null, prev_batch: null,
}, },
unread_notifications: roomData._unreadNotifications, unread_notifications: roomData._unreadNotifications,
summary: roomData._summary,
}; };
// Add account data // Add account data
Object.keys(roomData._accountData).forEach((evType) => { Object.keys(roomData._accountData).forEach((evType) => {

View File

@@ -33,6 +33,8 @@ const utils = require("./utils");
const Filter = require("./filter"); const Filter = require("./filter");
const EventTimeline = require("./models/event-timeline"); const EventTimeline = require("./models/event-timeline");
import {InvalidStoreError} from './errors';
const DEBUG = true; const DEBUG = true;
// /sync requests allow you to set a timeout= but the request may continue // /sync requests allow you to set a timeout= but the request may continue
@@ -93,12 +95,14 @@ function SyncApi(client, opts) {
this._peekRoomId = null; this._peekRoomId = null;
this._currentSyncRequest = null; this._currentSyncRequest = null;
this._syncState = null; this._syncState = null;
this._syncStateData = null; // additional data (eg. error object for failed sync)
this._catchingUp = false; this._catchingUp = false;
this._running = false; this._running = false;
this._keepAliveTimer = null; this._keepAliveTimer = null;
this._connectionReturnedDefer = null; this._connectionReturnedDefer = null;
this._notifEvents = []; // accumulator of sync events in the current sync response this._notifEvents = []; // accumulator of sync events in the current sync response
this._failedSyncCount = 0; // Number of consecutive failed /sync requests this._failedSyncCount = 0; // Number of consecutive failed /sync requests
this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
if (client.getNotifTimelineSet()) { if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet(), client.reEmitter.reEmit(client.getNotifTimelineSet(),
@@ -112,7 +116,8 @@ function SyncApi(client, opts) {
*/ */
SyncApi.prototype.createRoom = function(roomId) { SyncApi.prototype.createRoom = function(roomId) {
const client = this.client; const client = this.client;
const room = new Room(roomId, { const room = new Room(roomId, client, client.getUserId(), {
lazyLoadMembers: this.opts.lazyLoadMembers,
pendingEventOrdering: this.opts.pendingEventOrdering, pendingEventOrdering: this.opts.pendingEventOrdering,
timelineSupport: client.timelineSupport, timelineSupport: client.timelineSupport,
}); });
@@ -121,6 +126,7 @@ SyncApi.prototype.createRoom = function(roomId) {
"Room.timelineReset", "Room.timelineReset",
"Room.localEchoUpdated", "Room.localEchoUpdated",
"Room.accountData", "Room.accountData",
"Room.myMembership",
]); ]);
this._registerStateListeners(room); this._registerStateListeners(room);
return room; return room;
@@ -231,7 +237,7 @@ SyncApi.prototype.syncLeftRooms = function() {
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
room.recalculate(client.credentials.userId); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -302,7 +308,7 @@ SyncApi.prototype.peek = function(roomId) {
peekRoom.currentState.setStateEvents(stateEvents); peekRoom.currentState.setStateEvents(stateEvents);
self._resolveInvites(peekRoom); self._resolveInvites(peekRoom);
peekRoom.recalculate(self.client.credentials.userId); peekRoom.recalculate();
// roll backwards to diverge old state. addEventsToTimeline // roll backwards to diverge old state. addEventsToTimeline
// will overwrite the pagination token, so make sure it overwrites // will overwrite the pagination token, so make sure it overwrites
@@ -396,6 +402,18 @@ SyncApi.prototype.getSyncState = function() {
return this._syncState; return this._syncState;
}; };
/**
* Returns the additional data object associated with
* the current sync state, or null if there is no
* such data.
* Sync errors, if available, are put in the 'error' key of
* this object.
* @return {?Object}
*/
SyncApi.prototype.getSyncStateData = function() {
return this._syncStateData;
};
SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) { SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) {
// Wait for the saved sync to complete - we send the pushrules and filter requests // Wait for the saved sync to complete - we send the pushrules and filter requests
// before the saved sync has finished so they can run in parallel, but only process // before the saved sync has finished so they can run in parallel, but only process
@@ -407,6 +425,26 @@ SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise,
await keepaliveProm; await keepaliveProm;
}; };
/**
* Is the lazy loading option different than in previous session?
* @param {bool} lazyLoadMembers current options for lazy loading
* @return {bool} whether or not the option has changed compared to the previous session */
SyncApi.prototype._wasLazyLoadingToggled = async function(lazyLoadMembers) {
lazyLoadMembers = !!lazyLoadMembers;
// assume it was turned off before
// if we don't know any better
let lazyLoadMembersBefore = false;
const isStoreNewlyCreated = await this.client.store.isNewlyCreated();
if (!isStoreNewlyCreated) {
const prevClientOptions = await this.client.store.getClientOptions();
if (prevClientOptions) {
lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers;
}
return lazyLoadMembersBefore !== lazyLoadMembers;
}
return false;
};
/** /**
* Main entry point * Main entry point
*/ */
@@ -429,6 +467,8 @@ SyncApi.prototype.sync = function() {
// 1) We need to get push rules so we can check if events should bing as we get // 1) We need to get push rules so we can check if events should bing as we get
// them from /sync. // them from /sync.
// 2) We need to get/create a filter which we can use for /sync. // 2) We need to get/create a filter which we can use for /sync.
// 3) We need to check the lazy loading option matches what was used in the
// stored sync. If it doesn't, we can't use the stored sync.
async function getPushRules() { async function getPushRules() {
try { try {
@@ -443,9 +483,47 @@ SyncApi.prototype.sync = function() {
getPushRules(); getPushRules();
return; return;
} }
getFilter(); // Now get the filter and start syncing checkLazyLoadStatus(); // advance to the next stage
} }
const checkLazyLoadStatus = async () => {
if (this.opts.lazyLoadMembers && client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
const supported = await client.doesServerSupportLazyLoading();
if (supported) {
this.opts.filter = await client.createFilter(
Filter.LAZY_LOADING_SYNC_FILTER,
);
} else {
console.log("LL: lazy loading requested but not supported " +
"by server, so disabling");
this.opts.lazyLoadMembers = false;
}
}
// need to vape the store when enabling LL and wasn't enabled before
const shouldClear = await this._wasLazyLoadingToggled(this.opts.lazyLoadMembers);
if (shouldClear) {
this._storeIsInvalid = true;
const reason = InvalidStoreError.TOGGLED_LAZY_LOADING;
const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers);
this._updateSyncState("ERROR", { error });
// bail out of the sync loop now: the app needs to respond to this error.
// we leave the state as 'ERROR' which isn't great since this normally means
// we're retrying. The client must be stopped before clearing the stores anyway
// so the app should stop the client, clear the store and start it again.
console.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers && this._crypto) {
this.opts.crypto.enableLazyLoading();
}
await this.client._storeClientOptions();
getFilter(); // Now get the filter and start syncing
};
async function getFilter() { async function getFilter() {
let filter; let filter;
if (self.opts.filter) { if (self.opts.filter) {
@@ -573,7 +651,12 @@ SyncApi.prototype._syncFromCache = async function(savedSync) {
console.error("Error processing cached sync", e.stack || e); console.error("Error processing cached sync", e.stack || e);
} }
this._updateSyncState("PREPARED", syncEventData); // Don't emit a prepared if we've bailed because the store is invalid:
// in this case the client will not be usable until stopped & restarted
// so this would be useless and misleading.
if (!this._storeIsInvalid) {
this._updateSyncState("PREPARED", syncEventData);
}
}; };
/** /**
@@ -763,9 +846,20 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
// fails, since long lived HTTP connections will // fails, since long lived HTTP connections will
// go away sometimes and we shouldn't treat this as // go away sometimes and we shouldn't treat this as
// erroneous. We set the state to 'reconnecting' // erroneous. We set the state to 'reconnecting'
// instead, so that clients can onserve this state // instead, so that clients can observe this state
// if they wish. // if they wish.
this._startKeepAlives().then(() => { this._startKeepAlives().then((connDidFail) => {
// Only emit CATCHUP if we detected a connectivity error: if we didn't,
// it's quite likely the sync will fail again for the same reason and we
// want to stay in ERROR rather than keep flip-flopping between ERROR
// and CATCHUP.
if (connDidFail && this.getSyncState() === 'ERROR') {
this._updateSyncState("CATCHUP", {
oldSyncToken: null,
nextSyncToken: null,
catchingUp: true,
});
}
this._sync(syncOptions); this._sync(syncOptions);
}); });
@@ -774,6 +868,7 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
this._updateSyncState( this._updateSyncState(
this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ?
"ERROR" : "RECONNECTING", "ERROR" : "RECONNECTING",
{ error: err },
); );
}; };
@@ -809,6 +904,11 @@ SyncApi.prototype._processSyncResponse = async function(
// state: { events: [] }, // state: { events: [] },
// timeline: { events: [], prev_batch: $token, limited: true }, // timeline: { events: [], prev_batch: $token, limited: true },
// ephemeral: { events: [] }, // ephemeral: { events: [] },
// summary: {
// m.heroes: [ $user_id ],
// m.joined_member_count: $count,
// m.invited_member_count: $count
// },
// account_data: { events: [] }, // account_data: { events: [] },
// unread_notifications: { // unread_notifications: {
// highlight_count: 0, // highlight_count: 0,
@@ -947,9 +1047,11 @@ SyncApi.prototype._processSyncResponse = async function(
const room = inviteObj.room; const room = inviteObj.room;
const stateEvents = const stateEvents =
self._mapSyncEventsFormat(inviteObj.invite_state, room); self._mapSyncEventsFormat(inviteObj.invite_state, room);
room.updateMyMembership("invite");
self._processRoomEvents(room, stateEvents); self._processRoomEvents(room, stateEvents);
if (inviteObj.isBrandNewRoom) { if (inviteObj.isBrandNewRoom) {
room.recalculate(client.credentials.userId); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
} }
@@ -976,6 +1078,8 @@ SyncApi.prototype._processSyncResponse = async function(
); );
} }
room.updateMyMembership("join");
joinObj.timeline = joinObj.timeline || {}; joinObj.timeline = joinObj.timeline || {};
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
@@ -1040,6 +1144,13 @@ SyncApi.prototype._processSyncResponse = async function(
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
// set summary after processing events,
// because it will trigger a name calculation
// which needs the room state to be up to date
if (joinObj.summary) {
room.setSummary(joinObj.summary);
}
// XXX: should we be adding ephemeralEvents to the timeline? // XXX: should we be adding ephemeralEvents to the timeline?
// It feels like that for symmetry with room.addAccountData() // It feels like that for symmetry with room.addAccountData()
// there should be a room.addEphemeralEvents() or similar. // there should be a room.addEphemeralEvents() or similar.
@@ -1048,7 +1159,7 @@ SyncApi.prototype._processSyncResponse = async function(
// we deliberately don't add accountData to the timeline // we deliberately don't add accountData to the timeline
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
room.recalculate(client.credentials.userId); room.recalculate();
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -1083,10 +1194,12 @@ SyncApi.prototype._processSyncResponse = async function(
const accountDataEvents = const accountDataEvents =
self._mapSyncEventsFormat(leaveObj.account_data); self._mapSyncEventsFormat(leaveObj.account_data);
room.updateMyMembership("leave");
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
room.recalculate(client.credentials.userId); room.recalculate();
if (leaveObj.isBrandNewRoom) { if (leaveObj.isBrandNewRoom) {
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -1175,13 +1288,16 @@ SyncApi.prototype._startKeepAlives = function(delay) {
* *
* On failure, schedules a call back to itself. On success, resolves * On failure, schedules a call back to itself. On success, resolves
* this._connectionReturnedDefer. * this._connectionReturnedDefer.
*
* @param {bool} connDidFail True if a connectivity failure has been detected. Optional.
*/ */
SyncApi.prototype._pokeKeepAlive = function() { SyncApi.prototype._pokeKeepAlive = function(connDidFail) {
if (connDidFail === undefined) connDidFail = false;
const self = this; const self = this;
function success() { function success() {
clearTimeout(self._keepAliveTimer); clearTimeout(self._keepAliveTimer);
if (self._connectionReturnedDefer) { if (self._connectionReturnedDefer) {
self._connectionReturnedDefer.resolve(); self._connectionReturnedDefer.resolve(connDidFail);
self._connectionReturnedDefer = null; self._connectionReturnedDefer = null;
} }
} }
@@ -1198,7 +1314,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
).done(function() { ).done(function() {
success(); success();
}, function(err) { }, function(err) {
if (err.httpStatus == 400) { if (err.httpStatus == 400 || err.httpStatus == 404) {
// treat this as a success because the server probably just doesn't // treat this as a success because the server probably just doesn't
// support /versions: point is, we're getting a response. // support /versions: point is, we're getting a response.
// We wait a short time though, just in case somehow the server // We wait a short time though, just in case somehow the server
@@ -1206,8 +1322,9 @@ SyncApi.prototype._pokeKeepAlive = function() {
// responses fail, this will mean we don't hammer in a loop. // responses fail, this will mean we don't hammer in a loop.
self._keepAliveTimer = setTimeout(success, 2000); self._keepAliveTimer = setTimeout(success, 2000);
} else { } else {
connDidFail = true;
self._keepAliveTimer = setTimeout( self._keepAliveTimer = setTimeout(
self._pokeKeepAlive.bind(self), self._pokeKeepAlive.bind(self, connDidFail),
5000 + Math.floor(Math.random() * 5000), 5000 + Math.floor(Math.random() * 5000),
); );
// A keepalive has failed, so we emit the // A keepalive has failed, so we emit the
@@ -1215,7 +1332,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
// first failure). // first failure).
// Note we do this after setting the timer: // Note we do this after setting the timer:
// this lets the unit tests advance the mock // this lets the unit tests advance the mock
// clock when the get the error. // clock when they get the error.
self._updateSyncState("ERROR", { error: err }); self._updateSyncState("ERROR", { error: err });
} }
}); });
@@ -1376,7 +1493,7 @@ SyncApi.prototype._processRoomEvents = function(room, stateEventList,
// a recalculation (like m.room.name) we won't recalculate until we've // a recalculation (like m.room.name) we won't recalculate until we've
// finished adding all the events, which will cause the notification to have // finished adding all the events, which will cause the notification to have
// the old room name rather than the new one. // the old room name rather than the new one.
room.recalculate(this.client.credentials.userId); room.recalculate();
// If the timeline wasn't empty, we process the state events here: they're // If the timeline wasn't empty, we process the state events here: they're
// defined as updates to the state before the start of the timeline, so this // defined as updates to the state before the start of the timeline, so this
@@ -1451,6 +1568,7 @@ SyncApi.prototype._getGuestFilter = function() {
SyncApi.prototype._updateSyncState = function(newState, data) { SyncApi.prototype._updateSyncState = function(newState, data) {
const old = this._syncState; const old = this._syncState;
this._syncState = newState; this._syncState = newState;
this._syncStateData = data;
this.client.emit("sync", this._syncState, old, data); this.client.emit("sync", this._syncState, old, data);
}; };

View File

@@ -662,3 +662,13 @@ module.exports.inherits = function(ctor, superCtor) {
module.exports.isNumber = function(value) { module.exports.isNumber = function(value) {
return typeof value === 'number' && isFinite(value); return typeof value === 'number' && isFinite(value);
}; };
/**
* Removes zero width chars, diacritics and whitespace from the string
* @param {string} str the string to remove hidden characters from
* @return {string} a string with the hidden characters removed
*/
module.exports.removeHiddenChars = function(str) {
return str.normalize('NFD').replace(removeHiddenCharsRegex, '');
};
const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g;