Merge pull request #77 from vector-im/bwindels/devicetracking
Implement device tracking for E2EE rooms
This commit is contained in:
commit
cb940bf143
23 changed files with 602 additions and 55 deletions
|
@ -19,6 +19,7 @@ import { ObservableMap } from "../observable/index.js";
|
||||||
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
|
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
|
||||||
import {User} from "./User.js";
|
import {User} from "./User.js";
|
||||||
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
||||||
|
import {DeviceTracker} from "./e2ee/DeviceTracker.js";
|
||||||
const PICKLE_KEY = "DEFAULT_KEY";
|
const PICKLE_KEY = "DEFAULT_KEY";
|
||||||
|
|
||||||
export class Session {
|
export class Session {
|
||||||
|
@ -34,6 +35,11 @@ export class Session {
|
||||||
this._user = new User(sessionInfo.userId);
|
this._user = new User(sessionInfo.userId);
|
||||||
this._olm = olm;
|
this._olm = olm;
|
||||||
this._e2eeAccount = null;
|
this._e2eeAccount = null;
|
||||||
|
this._deviceTracker = olm ? new DeviceTracker({
|
||||||
|
storage,
|
||||||
|
getSyncToken: () => this.syncToken,
|
||||||
|
olm,
|
||||||
|
}) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async beforeFirstSync(isNewLogin) {
|
async beforeFirstSync(isNewLogin) {
|
||||||
|
@ -152,7 +158,7 @@ export class Session {
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeSync(syncResponse, syncFilterId, txn) {
|
async writeSync(syncResponse, syncFilterId, roomChanges, txn) {
|
||||||
const changes = {};
|
const changes = {};
|
||||||
const syncToken = syncResponse.next_batch;
|
const syncToken = syncResponse.next_batch;
|
||||||
const deviceOneTimeKeysCount = syncResponse.device_one_time_keys_count;
|
const deviceOneTimeKeysCount = syncResponse.device_one_time_keys_count;
|
||||||
|
@ -166,6 +172,17 @@ export class Session {
|
||||||
txn.session.set("sync", syncInfo);
|
txn.session.set("sync", syncInfo);
|
||||||
changes.syncInfo = syncInfo;
|
changes.syncInfo = syncInfo;
|
||||||
}
|
}
|
||||||
|
if (this._deviceTracker) {
|
||||||
|
for (const {room, changes} of roomChanges) {
|
||||||
|
if (room.isTrackingMembers && changes.memberChanges?.size) {
|
||||||
|
await this._deviceTracker.writeMemberChanges(room, changes.memberChanges, txn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deviceLists = syncResponse.device_lists;
|
||||||
|
if (deviceLists) {
|
||||||
|
await this._deviceTracker.writeDeviceChanges(deviceLists, txn);
|
||||||
|
}
|
||||||
|
}
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -100,11 +100,13 @@ export class Sync {
|
||||||
this._status.set(SyncStatus.Stopped);
|
this._status.set(SyncStatus.Stopped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
if (!this._error) {
|
||||||
await this._session.afterSyncCompleted();
|
try {
|
||||||
} catch (err) {
|
await this._session.afterSyncCompleted();
|
||||||
console.err("error during after sync completed, continuing to sync.", err.stack);
|
} catch (err) {
|
||||||
// swallowing error here apart from logging
|
console.err("error during after sync completed, continuing to sync.", err.stack);
|
||||||
|
// swallowing error here apart from logging
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -129,11 +131,11 @@ export class Sync {
|
||||||
storeNames.timelineEvents,
|
storeNames.timelineEvents,
|
||||||
storeNames.timelineFragments,
|
storeNames.timelineFragments,
|
||||||
storeNames.pendingEvents,
|
storeNames.pendingEvents,
|
||||||
|
storeNames.userIdentities,
|
||||||
]);
|
]);
|
||||||
const roomChanges = [];
|
const roomChanges = [];
|
||||||
let sessionChanges;
|
let sessionChanges;
|
||||||
try {
|
try {
|
||||||
sessionChanges = this._session.writeSync(response, syncFilterId, syncTxn);
|
|
||||||
// to_device
|
// to_device
|
||||||
// presence
|
// presence
|
||||||
if (response.rooms) {
|
if (response.rooms) {
|
||||||
|
@ -153,6 +155,7 @@ export class Sync {
|
||||||
});
|
});
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
sessionChanges = await this._session.writeSync(response, syncFilterId, roomChanges, syncTxn);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.warn("aborting syncTxn because of error");
|
console.warn("aborting syncTxn because of error");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
|
@ -15,14 +15,12 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import anotherjson from "../../../lib/another-json/index.js";
|
import anotherjson from "../../../lib/another-json/index.js";
|
||||||
|
import {SESSION_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
|
||||||
|
|
||||||
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
||||||
export const SESSION_KEY_PREFIX = "e2ee:";
|
|
||||||
const ACCOUNT_SESSION_KEY = SESSION_KEY_PREFIX + "olmAccount";
|
const ACCOUNT_SESSION_KEY = SESSION_KEY_PREFIX + "olmAccount";
|
||||||
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_KEY_PREFIX + "areDeviceKeysUploaded";
|
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_KEY_PREFIX + "areDeviceKeysUploaded";
|
||||||
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_KEY_PREFIX + "serverOTKCount";
|
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_KEY_PREFIX + "serverOTKCount";
|
||||||
const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
|
|
||||||
const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
|
|
||||||
|
|
||||||
export class Account {
|
export class Account {
|
||||||
static async load({olm, pickleKey, hsApi, userId, deviceId, txn}) {
|
static async load({olm, pickleKey, hsApi, userId, deviceId, txn}) {
|
||||||
|
|
280
src/matrix/e2ee/DeviceTracker.js
Normal file
280
src/matrix/e2ee/DeviceTracker.js
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import anotherjson from "../../../lib/another-json/index.js";
|
||||||
|
|
||||||
|
const TRACKING_STATUS_OUTDATED = 0;
|
||||||
|
const TRACKING_STATUS_UPTODATE = 1;
|
||||||
|
|
||||||
|
const DEVICE_KEYS_SIGNATURE_ALGORITHM = "ed25519";
|
||||||
|
|
||||||
|
// map 1 device from /keys/query response to DeviceIdentity
|
||||||
|
function deviceKeysAsDeviceIdentity(deviceSection) {
|
||||||
|
const deviceId = deviceSection["device_id"];
|
||||||
|
const userId = deviceSection["user_id"];
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
ed25519Key: deviceSection.keys?.[`ed25519:${deviceId}`],
|
||||||
|
curve25519Key: deviceSection.keys?.[`curve25519:${deviceId}`],
|
||||||
|
algorithms: deviceSection.algorithms,
|
||||||
|
displayName: deviceSection.unsigned?.device_display_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceTracker {
|
||||||
|
constructor({storage, getSyncToken, olm}) {
|
||||||
|
this._storage = storage;
|
||||||
|
this._getSyncToken = getSyncToken;
|
||||||
|
this._identityChangedForRoom = null;
|
||||||
|
this._olmUtil = new olm.Utility();
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeDeviceChanges(deviceLists, txn) {
|
||||||
|
const {userIdentities} = txn;
|
||||||
|
if (Array.isArray(deviceLists.changed) && deviceLists.changed.length) {
|
||||||
|
await Promise.all(deviceLists.changed.map(async userId => {
|
||||||
|
const user = await userIdentities.get(userId);
|
||||||
|
if (user) {
|
||||||
|
user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED;
|
||||||
|
userIdentities.set(user);
|
||||||
|
} else {
|
||||||
|
console.warn("changed device userid not found", userId);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeMemberChanges(room, memberChanges, txn) {
|
||||||
|
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
||||||
|
return this._applyMemberChange(memberChange, txn);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackRoom(room) {
|
||||||
|
if (room.isTrackingMembers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const memberList = await room.loadMemberList();
|
||||||
|
try {
|
||||||
|
const txn = await this._storage.readWriteTxn([
|
||||||
|
this._storage.storeNames.roomSummary,
|
||||||
|
this._storage.storeNames.userIdentities,
|
||||||
|
]);
|
||||||
|
let isTrackingChanges;
|
||||||
|
try {
|
||||||
|
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
|
||||||
|
const members = Array.from(memberList.members.values());
|
||||||
|
await this._writeJoinedMembers(members, txn);
|
||||||
|
} catch (err) {
|
||||||
|
txn.abort();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await txn.complete();
|
||||||
|
room.applyIsTrackingMembersChanges(isTrackingChanges);
|
||||||
|
} finally {
|
||||||
|
memberList.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeJoinedMembers(members, txn) {
|
||||||
|
await Promise.all(members.map(async member => {
|
||||||
|
if (member.membership === "join") {
|
||||||
|
await this._writeMember(member, txn);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeMember(member, txn) {
|
||||||
|
const {userIdentities} = txn;
|
||||||
|
const identity = await userIdentities.get(member.userId);
|
||||||
|
if (!identity) {
|
||||||
|
userIdentities.set({
|
||||||
|
userId: member.userId,
|
||||||
|
roomIds: [member.roomId],
|
||||||
|
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!identity.roomIds.includes(member.roomId)) {
|
||||||
|
identity.roomIds.push(member.roomId);
|
||||||
|
userIdentities.set(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _applyMemberChange(memberChange, txn) {
|
||||||
|
// TODO: depends whether we encrypt for invited users??
|
||||||
|
// add room
|
||||||
|
if (memberChange.previousMembership !== "join" && memberChange.membership === "join") {
|
||||||
|
await this._writeMember(memberChange.member, txn);
|
||||||
|
}
|
||||||
|
// remove room
|
||||||
|
else if (memberChange.previousMembership === "join" && memberChange.membership !== "join") {
|
||||||
|
const {userIdentities} = txn;
|
||||||
|
const identity = await userIdentities.get(memberChange.userId);
|
||||||
|
if (identity) {
|
||||||
|
identity.roomIds = identity.roomIds.filter(roomId => roomId !== memberChange.roomId);
|
||||||
|
// no more encrypted rooms with this user, remove
|
||||||
|
if (identity.roomIds.length === 0) {
|
||||||
|
userIdentities.remove(identity.userId);
|
||||||
|
} else {
|
||||||
|
userIdentities.set(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _queryKeys(userIds, hsApi) {
|
||||||
|
// TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ...
|
||||||
|
// there are multiple requests going out for /keys/query though and only one for /members
|
||||||
|
|
||||||
|
const deviceKeyResponse = await hsApi.queryKeys({
|
||||||
|
"timeout": 10000,
|
||||||
|
"device_keys": userIds.reduce((deviceKeysMap, userId) => {
|
||||||
|
deviceKeysMap[userId] = [];
|
||||||
|
return deviceKeysMap;
|
||||||
|
}, {}),
|
||||||
|
"token": this._getSyncToken()
|
||||||
|
}).response();
|
||||||
|
|
||||||
|
const verifiedKeysPerUser = this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"]);
|
||||||
|
const flattenedVerifiedKeysPerUser = verifiedKeysPerUser.reduce((all, {verifiedKeys}) => all.concat(verifiedKeys), []);
|
||||||
|
const deviceIdentitiesWithPossibleChangedKeys = flattenedVerifiedKeysPerUser.map(deviceKeysAsDeviceIdentity);
|
||||||
|
|
||||||
|
const txn = await this._storage.readWriteTxn([
|
||||||
|
this._storage.storeNames.userIdentities,
|
||||||
|
this._storage.storeNames.deviceIdentities,
|
||||||
|
]);
|
||||||
|
let deviceIdentities;
|
||||||
|
try {
|
||||||
|
// check ed25519 key has not changed if we've seen the device before
|
||||||
|
deviceIdentities = await Promise.all(deviceIdentitiesWithPossibleChangedKeys.map(async (deviceIdentity) => {
|
||||||
|
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
||||||
|
if (!existingDevice || existingDevice.ed25519Key === deviceIdentity.ed25519Key) {
|
||||||
|
return deviceIdentity;
|
||||||
|
}
|
||||||
|
// ignore devices where the keys have changed
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
// filter out nulls
|
||||||
|
deviceIdentities = deviceIdentities.filter(di => !!di);
|
||||||
|
// store devices
|
||||||
|
for (const deviceIdentity of deviceIdentities) {
|
||||||
|
txn.deviceIdentities.set(deviceIdentity);
|
||||||
|
}
|
||||||
|
// mark user identities as up to date
|
||||||
|
await Promise.all(verifiedKeysPerUser.map(async ({userId}) => {
|
||||||
|
const identity = await txn.userIdentities.get(userId);
|
||||||
|
identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
|
||||||
|
txn.userIdentities.set(identity);
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
txn.abort();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await txn.complete();
|
||||||
|
return deviceIdentities;
|
||||||
|
}
|
||||||
|
|
||||||
|
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse) {
|
||||||
|
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
|
||||||
|
const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => {
|
||||||
|
const deviceIdOnKeys = deviceKeys["device_id"];
|
||||||
|
const userIdOnKeys = deviceKeys["user_id"];
|
||||||
|
if (userIdOnKeys !== userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (deviceIdOnKeys !== deviceId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this._verifyUserDeviceKeys(deviceKeys);
|
||||||
|
});
|
||||||
|
const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys);
|
||||||
|
return {userId, verifiedKeys};
|
||||||
|
});
|
||||||
|
return verifiedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
_verifyUserDeviceKeys(deviceSection) {
|
||||||
|
const deviceId = deviceSection["device_id"];
|
||||||
|
const userId = deviceSection["user_id"];
|
||||||
|
const clone = Object.assign({}, deviceSection);
|
||||||
|
delete clone.unsigned;
|
||||||
|
delete clone.signatures;
|
||||||
|
const canonicalJson = anotherjson.stringify(clone);
|
||||||
|
const key = deviceSection?.keys?.[`${DEVICE_KEYS_SIGNATURE_ALGORITHM}:${deviceId}`];
|
||||||
|
const signature = deviceSection?.signatures?.[userId]?.[`${DEVICE_KEYS_SIGNATURE_ALGORITHM}:${deviceId}`];
|
||||||
|
try {
|
||||||
|
if (!signature) {
|
||||||
|
throw new Error("no signature");
|
||||||
|
}
|
||||||
|
// throws when signature is invalid
|
||||||
|
this._olmUtil.ed25519_verify(key, canonicalJson, signature);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Invalid device signature, ignoring device.", key, canonicalJson, signature, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives all the device identities for a room that is already tracked.
|
||||||
|
* Assumes room is already tracked. Call `trackRoom` first if unsure.
|
||||||
|
* @param {String} roomId [description]
|
||||||
|
* @return {[type]} [description]
|
||||||
|
*/
|
||||||
|
async deviceIdentitiesForTrackedRoom(roomId, hsApi) {
|
||||||
|
let identities;
|
||||||
|
const txn = await this._storage.readTxn([
|
||||||
|
this._storage.storeNames.roomMembers,
|
||||||
|
this._storage.storeNames.userIdentities,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// because we don't have multiEntry support in IE11, we get a set of userIds that is pretty close to what we
|
||||||
|
// need as a good first filter (given that non-join memberships will be in there). After fetching the identities,
|
||||||
|
// we check which ones have the roomId for the room we're looking at.
|
||||||
|
|
||||||
|
// So, this will also contain non-joined memberships
|
||||||
|
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
||||||
|
const allMemberIdentities = await Promise.all(userIds.map(userId => txn.userIdentities.get(userId)));
|
||||||
|
identities = allMemberIdentities.filter(identity => {
|
||||||
|
// identity will be missing for any userIds that don't have
|
||||||
|
// membership join in any of your encrypted rooms
|
||||||
|
return identity && identity.roomIds.includes(roomId);
|
||||||
|
});
|
||||||
|
const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE);
|
||||||
|
const outdatedIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED);
|
||||||
|
let queriedDevices;
|
||||||
|
if (outdatedIdentities.length) {
|
||||||
|
// TODO: ignore the race between /sync and /keys/query for now,
|
||||||
|
// where users could get marked as outdated or added/removed from the room while
|
||||||
|
// querying keys
|
||||||
|
queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTxn = await this._storage.readTxn([
|
||||||
|
this._storage.storeNames.deviceIdentities,
|
||||||
|
]);
|
||||||
|
const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {
|
||||||
|
return deviceTxn.deviceIdentities.getAllForUserId(identity.userId);
|
||||||
|
}));
|
||||||
|
let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []);
|
||||||
|
if (queriedDevices && queriedDevices.length) {
|
||||||
|
flattenedDevices = flattenedDevices.concat(queriedDevices);
|
||||||
|
}
|
||||||
|
return flattenedDevices;
|
||||||
|
}
|
||||||
|
}
|
20
src/matrix/e2ee/common.js
Normal file
20
src/matrix/e2ee/common.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
||||||
|
export const SESSION_KEY_PREFIX = "e2ee:";
|
||||||
|
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
|
||||||
|
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
|
|
@ -164,6 +164,10 @@ export class HomeServerApi {
|
||||||
return this._post("/keys/upload", null, payload, options);
|
return this._post("/keys/upload", null, payload, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queryKeys(queryRequest, options = null) {
|
||||||
|
return this._post("/keys/query", null, queryRequest, options);
|
||||||
|
}
|
||||||
|
|
||||||
get mediaRepository() {
|
get mediaRepository() {
|
||||||
return this._mediaRepository;
|
return this._mediaRepository;
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,7 @@ export class Room extends EventEmitter {
|
||||||
this._timeline = null;
|
this._timeline = null;
|
||||||
this._user = user;
|
this._user = user;
|
||||||
this._changedMembersDuringSync = null;
|
this._changedMembersDuringSync = null;
|
||||||
|
this._memberList = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
|
@ -50,7 +51,7 @@ export class Room extends EventEmitter {
|
||||||
membership,
|
membership,
|
||||||
isInitialSync, isTimelineOpen,
|
isInitialSync, isTimelineOpen,
|
||||||
txn);
|
txn);
|
||||||
const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
|
const {entries, newLiveKey, memberChanges} = await this._syncWriter.writeSync(roomResponse, txn);
|
||||||
// fetch new members while we have txn open,
|
// fetch new members while we have txn open,
|
||||||
// but don't make any in-memory changes yet
|
// but don't make any in-memory changes yet
|
||||||
let heroChanges;
|
let heroChanges;
|
||||||
|
@ -59,7 +60,7 @@ export class Room extends EventEmitter {
|
||||||
if (!this._heroes) {
|
if (!this._heroes) {
|
||||||
this._heroes = new Heroes(this._roomId);
|
this._heroes = new Heroes(this._roomId);
|
||||||
}
|
}
|
||||||
heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, changedMembers, txn);
|
heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, memberChanges, txn);
|
||||||
}
|
}
|
||||||
let removedPendingEvents;
|
let removedPendingEvents;
|
||||||
if (roomResponse.timeline && roomResponse.timeline.events) {
|
if (roomResponse.timeline && roomResponse.timeline.events) {
|
||||||
|
@ -70,22 +71,22 @@ export class Room extends EventEmitter {
|
||||||
newTimelineEntries: entries,
|
newTimelineEntries: entries,
|
||||||
newLiveKey,
|
newLiveKey,
|
||||||
removedPendingEvents,
|
removedPendingEvents,
|
||||||
changedMembers,
|
memberChanges,
|
||||||
heroChanges
|
heroChanges
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers, heroChanges}) {
|
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) {
|
||||||
this._syncWriter.afterSync(newLiveKey);
|
this._syncWriter.afterSync(newLiveKey);
|
||||||
if (changedMembers.length) {
|
if (memberChanges.size) {
|
||||||
if (this._changedMembersDuringSync) {
|
if (this._changedMembersDuringSync) {
|
||||||
for (const member of changedMembers) {
|
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||||
this._changedMembersDuringSync.set(member.userId, member);
|
this._changedMembersDuringSync.set(userId, memberChange.member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._memberList) {
|
if (this._memberList) {
|
||||||
this._memberList.afterSync(changedMembers);
|
this._memberList.afterSync(memberChanges);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let emitChange = false;
|
let emitChange = false;
|
||||||
|
@ -144,6 +145,7 @@ export class Room extends EventEmitter {
|
||||||
/** @public */
|
/** @public */
|
||||||
async loadMemberList() {
|
async loadMemberList() {
|
||||||
if (this._memberList) {
|
if (this._memberList) {
|
||||||
|
// TODO: also await fetchOrLoadMembers promise here
|
||||||
this._memberList.retain();
|
this._memberList.retain();
|
||||||
return this._memberList;
|
return this._memberList;
|
||||||
} else {
|
} else {
|
||||||
|
@ -256,6 +258,14 @@ export class Room extends EventEmitter {
|
||||||
return !!(tags && tags['m.lowpriority']);
|
return !!(tags && tags['m.lowpriority']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isEncrypted() {
|
||||||
|
return !!this._summary.encryption;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTrackingMembers() {
|
||||||
|
return this._summary.isTrackingMembers;
|
||||||
|
}
|
||||||
|
|
||||||
async _getLastEventId() {
|
async _getLastEventId() {
|
||||||
const lastKey = this._syncWriter.lastMessageKey;
|
const lastKey = this._syncWriter.lastMessageKey;
|
||||||
if (lastKey) {
|
if (lastKey) {
|
||||||
|
@ -322,5 +332,15 @@ export class Room extends EventEmitter {
|
||||||
get mediaRepository() {
|
get mediaRepository() {
|
||||||
return this._hsApi.mediaRepository;
|
return this._hsApi.mediaRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @package */
|
||||||
|
writeIsTrackingMembers(value, txn) {
|
||||||
|
return this._summary.writeIsTrackingMembers(value, txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @package */
|
||||||
|
applyIsTrackingMembersChanges(changes) {
|
||||||
|
this._summary.applyChanges(changes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
|
||||||
|
|
||||||
function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
|
function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
|
||||||
if (roomResponse.summary) {
|
if (roomResponse.summary) {
|
||||||
data = updateSummary(data, roomResponse.summary);
|
data = updateSummary(data, roomResponse.summary);
|
||||||
|
@ -68,9 +70,10 @@ function processRoomAccountData(data, event) {
|
||||||
|
|
||||||
function processStateEvent(data, event) {
|
function processStateEvent(data, event) {
|
||||||
if (event.type === "m.room.encryption") {
|
if (event.type === "m.room.encryption") {
|
||||||
if (!data.isEncrypted) {
|
const algorithm = event.content?.algorithm;
|
||||||
|
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
|
||||||
data = data.cloneIfNeeded();
|
data = data.cloneIfNeeded();
|
||||||
data.isEncrypted = true;
|
data.encryption = event.content;
|
||||||
}
|
}
|
||||||
} else if (event.type === "m.room.name") {
|
} else if (event.type === "m.room.name") {
|
||||||
const newName = event.content?.name;
|
const newName = event.content?.name;
|
||||||
|
@ -113,7 +116,9 @@ function updateSummary(data, summary) {
|
||||||
const heroes = summary["m.heroes"];
|
const heroes = summary["m.heroes"];
|
||||||
const joinCount = summary["m.joined_member_count"];
|
const joinCount = summary["m.joined_member_count"];
|
||||||
const inviteCount = summary["m.invited_member_count"];
|
const inviteCount = summary["m.invited_member_count"];
|
||||||
|
// TODO: we could easily calculate if all members are available here and set hasFetchedMembers?
|
||||||
|
// so we can avoid calling /members...
|
||||||
|
// we'd need to do a count query in the roomMembers store though ...
|
||||||
if (heroes && Array.isArray(heroes)) {
|
if (heroes && Array.isArray(heroes)) {
|
||||||
data = data.cloneIfNeeded();
|
data = data.cloneIfNeeded();
|
||||||
data.heroes = heroes;
|
data.heroes = heroes;
|
||||||
|
@ -136,7 +141,7 @@ class SummaryData {
|
||||||
this.lastMessageBody = copy ? copy.lastMessageBody : null;
|
this.lastMessageBody = copy ? copy.lastMessageBody : null;
|
||||||
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
|
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
|
||||||
this.isUnread = copy ? copy.isUnread : false;
|
this.isUnread = copy ? copy.isUnread : false;
|
||||||
this.isEncrypted = copy ? copy.isEncrypted : false;
|
this.encryption = copy ? copy.encryption : null;
|
||||||
this.isDirectMessage = copy ? copy.isDirectMessage : false;
|
this.isDirectMessage = copy ? copy.isDirectMessage : false;
|
||||||
this.membership = copy ? copy.membership : null;
|
this.membership = copy ? copy.membership : null;
|
||||||
this.inviteCount = copy ? copy.inviteCount : 0;
|
this.inviteCount = copy ? copy.inviteCount : 0;
|
||||||
|
@ -144,6 +149,7 @@ class SummaryData {
|
||||||
this.heroes = copy ? copy.heroes : null;
|
this.heroes = copy ? copy.heroes : null;
|
||||||
this.canonicalAlias = copy ? copy.canonicalAlias : null;
|
this.canonicalAlias = copy ? copy.canonicalAlias : null;
|
||||||
this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false;
|
this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false;
|
||||||
|
this.isTrackingMembers = copy ? copy.isTrackingMembers : false;
|
||||||
this.lastPaginationToken = copy ? copy.lastPaginationToken : null;
|
this.lastPaginationToken = copy ? copy.lastPaginationToken : null;
|
||||||
this.avatarUrl = copy ? copy.avatarUrl : null;
|
this.avatarUrl = copy ? copy.avatarUrl : null;
|
||||||
this.notificationCount = copy ? copy.notificationCount : 0;
|
this.notificationCount = copy ? copy.notificationCount : 0;
|
||||||
|
@ -190,6 +196,11 @@ export class RoomSummary {
|
||||||
return this._data.heroes;
|
return this._data.heroes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get encryption() {
|
||||||
|
return this._data.encryption;
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether the room name should be determined with Heroes
|
||||||
get needsHeroes() {
|
get needsHeroes() {
|
||||||
return needsHeroes(this._data);
|
return needsHeroes(this._data);
|
||||||
}
|
}
|
||||||
|
@ -230,6 +241,10 @@ export class RoomSummary {
|
||||||
return this._data.hasFetchedMembers;
|
return this._data.hasFetchedMembers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isTrackingMembers() {
|
||||||
|
return this._data.isTrackingMembers;
|
||||||
|
}
|
||||||
|
|
||||||
get lastPaginationToken() {
|
get lastPaginationToken() {
|
||||||
return this._data.lastPaginationToken;
|
return this._data.lastPaginationToken;
|
||||||
}
|
}
|
||||||
|
@ -254,6 +269,13 @@ export class RoomSummary {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeIsTrackingMembers(value, txn) {
|
||||||
|
const data = new SummaryData(this._data);
|
||||||
|
data.isTrackingMembers = value;
|
||||||
|
txn.roomSummary.set(data.serialize());
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
|
writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
|
||||||
// clear cloned flag, so cloneIfNeeded makes a copy and
|
// clear cloned flag, so cloneIfNeeded makes a copy and
|
||||||
// this._data is not modified if any field is changed.
|
// this._data is not modified if any field is changed.
|
||||||
|
|
21
src/matrix/room/common.js
Normal file
21
src/matrix/room/common.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getPrevContentFromStateEvent(event) {
|
||||||
|
// where to look for prev_content is a bit of a mess,
|
||||||
|
// see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz
|
||||||
|
return event.unsigned?.prev_content || event.prev_content;
|
||||||
|
}
|
|
@ -42,11 +42,11 @@ export class Heroes {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} newHeroes array of user ids
|
* @param {string[]} newHeroes array of user ids
|
||||||
* @param {RoomMember[]} changedMembers array of changed members in this sync
|
* @param {Map<string, MemberChange>} memberChanges map of changed memberships
|
||||||
* @param {Transaction} txn
|
* @param {Transaction} txn
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
async calculateChanges(newHeroes, changedMembers, txn) {
|
async calculateChanges(newHeroes, memberChanges, txn) {
|
||||||
const updatedHeroMembers = new Map();
|
const updatedHeroMembers = new Map();
|
||||||
const removedUserIds = [];
|
const removedUserIds = [];
|
||||||
// remove non-present members
|
// remove non-present members
|
||||||
|
@ -56,9 +56,9 @@ export class Heroes {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// update heroes with synced member changes
|
// update heroes with synced member changes
|
||||||
for (const member of changedMembers) {
|
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||||
if (this._members.has(member.userId) || newHeroes.indexOf(member.userId) !== -1) {
|
if (this._members.has(userId) || newHeroes.indexOf(userId) !== -1) {
|
||||||
updatedHeroMembers.set(member.userId, member);
|
updatedHeroMembers.set(userId, memberChange.member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// load member for new heroes from storage
|
// load member for new heroes from storage
|
||||||
|
|
|
@ -26,9 +26,9 @@ export class MemberList {
|
||||||
this._retentionCount = 1;
|
this._retentionCount = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSync(updatedMembers) {
|
afterSync(memberChanges) {
|
||||||
for (const member of updatedMembers) {
|
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||||
this._members.add(member.userId, member);
|
this._members.add(userId, memberChange.member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
|
||||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@ -15,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {getPrevContentFromStateEvent} from "../common.js";
|
||||||
|
|
||||||
export const EVENT_TYPE = "m.room.member";
|
export const EVENT_TYPE = "m.room.member";
|
||||||
|
|
||||||
export class RoomMember {
|
export class RoomMember {
|
||||||
|
@ -28,7 +29,7 @@ export class RoomMember {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = memberEvent.content;
|
const content = memberEvent.content;
|
||||||
const prevContent = memberEvent.unsigned?.prev_content;
|
const prevContent = getPrevContentFromStateEvent(memberEvent);
|
||||||
const membership = content?.membership;
|
const membership = content?.membership;
|
||||||
// fall back to prev_content for these as synapse doesn't (always?)
|
// fall back to prev_content for these as synapse doesn't (always?)
|
||||||
// put them on content for "leave" memberships
|
// put them on content for "leave" memberships
|
||||||
|
@ -45,7 +46,7 @@ export class RoomMember {
|
||||||
if (typeof userId !== "string") {
|
if (typeof userId !== "string") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = memberEvent.unsigned?.prev_content
|
const content = getPrevContentFromStateEvent(memberEvent);
|
||||||
return this._validateAndCreateMember(roomId, userId,
|
return this._validateAndCreateMember(roomId, userId,
|
||||||
content?.membership,
|
content?.membership,
|
||||||
content?.displayname,
|
content?.displayname,
|
||||||
|
@ -66,6 +67,10 @@ export class RoomMember {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get membership() {
|
||||||
|
return this._data.membership;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {String?} the display name, if any
|
* @return {String?} the display name, if any
|
||||||
*/
|
*/
|
||||||
|
@ -99,3 +104,34 @@ export class RoomMember {
|
||||||
return this._data;
|
return this._data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MemberChange {
|
||||||
|
constructor(roomId, memberEvent) {
|
||||||
|
this._roomId = roomId;
|
||||||
|
this._memberEvent = memberEvent;
|
||||||
|
this._member = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get member() {
|
||||||
|
if (!this._member) {
|
||||||
|
this._member = RoomMember.fromMemberEvent(this._roomId, this._memberEvent);
|
||||||
|
}
|
||||||
|
return this._member;
|
||||||
|
}
|
||||||
|
|
||||||
|
get roomId() {
|
||||||
|
return this._roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId() {
|
||||||
|
return this._memberEvent.state_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
get previousMembership() {
|
||||||
|
return getPrevContentFromStateEvent(this._memberEvent)?.membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
get membership() {
|
||||||
|
return this._memberEvent.content?.membership;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersM
|
||||||
const changedMembersDuringSync = new Map();
|
const changedMembersDuringSync = new Map();
|
||||||
setChangedMembersMap(changedMembersDuringSync);
|
setChangedMembersMap(changedMembersDuringSync);
|
||||||
|
|
||||||
const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response;
|
const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response();
|
||||||
|
|
||||||
const txn = await storage.readWriteTxn([
|
const txn = await storage.readWriteTxn([
|
||||||
storage.storeNames.roomSummary,
|
storage.storeNames.roomSummary,
|
||||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {BaseEntry} from "./BaseEntry.js";
|
import {BaseEntry} from "./BaseEntry.js";
|
||||||
|
import {getPrevContentFromStateEvent} from "../../common.js";
|
||||||
|
|
||||||
export class EventEntry extends BaseEntry {
|
export class EventEntry extends BaseEntry {
|
||||||
constructor(eventEntry, fragmentIdComparer) {
|
constructor(eventEntry, fragmentIdComparer) {
|
||||||
|
@ -35,7 +36,7 @@ export class EventEntry extends BaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
get prevContent() {
|
get prevContent() {
|
||||||
return this._eventEntry.event.unsigned?.prev_content;
|
return getPrevContentFromStateEvent(this._eventEntry.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
get eventType() {
|
get eventType() {
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {EventKey} from "../EventKey.js";
|
||||||
import {EventEntry} from "../entries/EventEntry.js";
|
import {EventEntry} from "../entries/EventEntry.js";
|
||||||
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
|
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
|
||||||
import {createEventEntry} from "./common.js";
|
import {createEventEntry} from "./common.js";
|
||||||
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
|
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
|
||||||
|
|
||||||
// Synapse bug? where the m.room.create event appears twice in sync response
|
// Synapse bug? where the m.room.create event appears twice in sync response
|
||||||
// when first syncing the room
|
// when first syncing the room
|
||||||
|
@ -102,13 +102,13 @@ export class SyncWriter {
|
||||||
if (event.type === MEMBER_EVENT_TYPE) {
|
if (event.type === MEMBER_EVENT_TYPE) {
|
||||||
const userId = event.state_key;
|
const userId = event.state_key;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const member = RoomMember.fromMemberEvent(this._roomId, event);
|
const memberChange = new MemberChange(this._roomId, event);
|
||||||
if (member) {
|
if (memberChange.member) {
|
||||||
// as this is sync, we can just replace the member
|
// as this is sync, we can just replace the member
|
||||||
// if it is there already
|
// if it is there already
|
||||||
txn.roomMembers.set(member.serialize());
|
txn.roomMembers.set(memberChange.member.serialize());
|
||||||
|
return memberChange;
|
||||||
}
|
}
|
||||||
return member;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
txn.roomState.set(this._roomId, event);
|
txn.roomState.set(this._roomId, event);
|
||||||
|
@ -116,22 +116,22 @@ export class SyncWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeStateEvents(roomResponse, txn) {
|
_writeStateEvents(roomResponse, txn) {
|
||||||
const changedMembers = [];
|
const memberChanges = new Map();
|
||||||
// persist state
|
// persist state
|
||||||
const {state} = roomResponse;
|
const {state} = roomResponse;
|
||||||
if (Array.isArray(state?.events)) {
|
if (Array.isArray(state?.events)) {
|
||||||
for (const event of state.events) {
|
for (const event of state.events) {
|
||||||
const member = this._writeStateEvent(event, txn);
|
const memberChange = this._writeStateEvent(event, txn);
|
||||||
if (member) {
|
if (memberChange) {
|
||||||
changedMembers.push(member);
|
memberChanges.set(memberChange.userId, memberChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changedMembers;
|
return memberChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _writeTimeline(entries, timeline, currentKey, txn) {
|
async _writeTimeline(entries, timeline, currentKey, txn) {
|
||||||
const changedMembers = [];
|
const memberChanges = new Map();
|
||||||
if (timeline.events) {
|
if (timeline.events) {
|
||||||
const events = deduplicateEvents(timeline.events);
|
const events = deduplicateEvents(timeline.events);
|
||||||
for(const event of events) {
|
for(const event of events) {
|
||||||
|
@ -148,14 +148,14 @@ export class SyncWriter {
|
||||||
|
|
||||||
// process live state events first, so new member info is available
|
// process live state events first, so new member info is available
|
||||||
if (typeof event.state_key === "string") {
|
if (typeof event.state_key === "string") {
|
||||||
const member = this._writeStateEvent(event, txn);
|
const memberChange = this._writeStateEvent(event, txn);
|
||||||
if (member) {
|
if (memberChange) {
|
||||||
changedMembers.push(member);
|
memberChanges.set(memberChange.userId, memberChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {currentKey, changedMembers};
|
return {currentKey, memberChanges};
|
||||||
}
|
}
|
||||||
|
|
||||||
async _findMemberData(userId, events, txn) {
|
async _findMemberData(userId, events, txn) {
|
||||||
|
@ -198,12 +198,14 @@ export class SyncWriter {
|
||||||
}
|
}
|
||||||
// important this happens before _writeTimeline so
|
// important this happens before _writeTimeline so
|
||||||
// members are available in the transaction
|
// members are available in the transaction
|
||||||
const changedMembers = this._writeStateEvents(roomResponse, txn);
|
const memberChanges = this._writeStateEvents(roomResponse, txn);
|
||||||
const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn);
|
const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn);
|
||||||
currentKey = timelineResult.currentKey;
|
currentKey = timelineResult.currentKey;
|
||||||
changedMembers.push(...timelineResult.changedMembers);
|
// merge member changes from state and timeline, giving precedence to the latter
|
||||||
|
for (const [userId, memberChange] of timelineResult.memberChanges.entries()) {
|
||||||
return {entries, newLiveKey: currentKey, changedMembers};
|
memberChanges.set(userId, memberChange);
|
||||||
|
}
|
||||||
|
return {entries, newLiveKey: currentKey, memberChanges};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSync(newLiveKey) {
|
afterSync(newLiveKey) {
|
||||||
|
|
|
@ -22,6 +22,8 @@ export const STORE_NAMES = Object.freeze([
|
||||||
"timelineEvents",
|
"timelineEvents",
|
||||||
"timelineFragments",
|
"timelineFragments",
|
||||||
"pendingEvents",
|
"pendingEvents",
|
||||||
|
"userIdentities",
|
||||||
|
"deviceIdentities",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
|
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
|
||||||
|
|
|
@ -105,6 +105,13 @@ export class QueryTarget {
|
||||||
return maxKey;
|
return maxKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async iterateKeys(range, callback) {
|
||||||
|
const cursor = this._target.openKeyCursor(range, "next");
|
||||||
|
await iterateCursor(cursor, (_, key) => {
|
||||||
|
return {done: callback(key)};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a given set of keys exist.
|
* Checks if a given set of keys exist.
|
||||||
* Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).
|
* Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).
|
||||||
|
|
|
@ -24,6 +24,8 @@ import {RoomStateStore} from "./stores/RoomStateStore.js";
|
||||||
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
|
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
|
||||||
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
|
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
|
||||||
import {PendingEventStore} from "./stores/PendingEventStore.js";
|
import {PendingEventStore} from "./stores/PendingEventStore.js";
|
||||||
|
import {UserIdentityStore} from "./stores/UserIdentityStore.js";
|
||||||
|
import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js";
|
||||||
|
|
||||||
export class Transaction {
|
export class Transaction {
|
||||||
constructor(txn, allowedStoreNames) {
|
constructor(txn, allowedStoreNames) {
|
||||||
|
@ -81,6 +83,14 @@ export class Transaction {
|
||||||
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
|
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userIdentities() {
|
||||||
|
return this._store("userIdentities", idbStore => new UserIdentityStore(idbStore));
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceIdentities() {
|
||||||
|
return this._store("deviceIdentities", idbStore => new DeviceIdentityStore(idbStore));
|
||||||
|
}
|
||||||
|
|
||||||
complete() {
|
complete() {
|
||||||
return txnAsPromise(this._txn);
|
return txnAsPromise(this._txn);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ export const schema = [
|
||||||
createInitialStores,
|
createInitialStores,
|
||||||
createMemberStore,
|
createMemberStore,
|
||||||
migrateSession,
|
migrateSession,
|
||||||
|
createIdentityStores,
|
||||||
];
|
];
|
||||||
// TODO: how to deal with git merge conflicts of this array?
|
// TODO: how to deal with git merge conflicts of this array?
|
||||||
|
|
||||||
|
@ -46,7 +47,7 @@ async function createMemberStore(db, txn) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
//v3
|
||||||
async function migrateSession(db, txn) {
|
async function migrateSession(db, txn) {
|
||||||
const session = txn.objectStore("session");
|
const session = txn.objectStore("session");
|
||||||
try {
|
try {
|
||||||
|
@ -64,3 +65,8 @@ async function migrateSession(db, txn) {
|
||||||
console.error("could not migrate session", err.stack);
|
console.error("could not migrate session", err.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//v4
|
||||||
|
function createIdentityStores(db) {
|
||||||
|
db.createObjectStore("userIdentities", {keyPath: "userId"});
|
||||||
|
db.createObjectStore("deviceIdentities", {keyPath: "key"});
|
||||||
|
}
|
||||||
|
|
41
src/matrix/storage/idb/stores/DeviceIdentityStore.js
Normal file
41
src/matrix/storage/idb/stores/DeviceIdentityStore.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function encodeKey(userId, deviceId) {
|
||||||
|
return `${userId}|${deviceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceIdentityStore {
|
||||||
|
constructor(store) {
|
||||||
|
this._store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllForUserId(userId) {
|
||||||
|
const range = IDBKeyRange.lowerBound(encodeKey(userId, ""));
|
||||||
|
return this._store.selectWhile(range, device => {
|
||||||
|
return device.userId === userId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(userId, deviceId) {
|
||||||
|
return this._store.get(encodeKey(userId, deviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
set(deviceIdentity) {
|
||||||
|
deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
|
||||||
|
return this._store.put(deviceIdentity);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,11 @@ function encodeKey(roomId, userId) {
|
||||||
return `${roomId}|${userId}`;
|
return `${roomId}|${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeKey(key) {
|
||||||
|
const [roomId, userId] = key.split("|");
|
||||||
|
return {roomId, userId};
|
||||||
|
}
|
||||||
|
|
||||||
// no historical members
|
// no historical members
|
||||||
export class RoomMemberStore {
|
export class RoomMemberStore {
|
||||||
constructor(roomMembersStore) {
|
constructor(roomMembersStore) {
|
||||||
|
@ -40,4 +45,19 @@ export class RoomMemberStore {
|
||||||
return member.roomId === roomId;
|
return member.roomId === roomId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllUserIds(roomId) {
|
||||||
|
const userIds = [];
|
||||||
|
const range = IDBKeyRange.lowerBound(encodeKey(roomId, ""));
|
||||||
|
await this._roomMembersStore.iterateKeys(range, key => {
|
||||||
|
const decodedKey = decodeKey(key);
|
||||||
|
// prevent running into the next room
|
||||||
|
if (decodedKey.roomId === roomId) {
|
||||||
|
userIds.push(decodedKey.userId);
|
||||||
|
return false; // fetch more
|
||||||
|
}
|
||||||
|
return true; // done
|
||||||
|
});
|
||||||
|
return userIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
33
src/matrix/storage/idb/stores/UserIdentityStore.js
Normal file
33
src/matrix/storage/idb/stores/UserIdentityStore.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UserIdentityStore {
|
||||||
|
constructor(store) {
|
||||||
|
this._store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(userId) {
|
||||||
|
return this._store.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(userIdentity) {
|
||||||
|
this._store.put(userIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(userId) {
|
||||||
|
return this._store.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -70,6 +70,10 @@ export class ObservableMap extends BaseObservableMap {
|
||||||
[Symbol.iterator]() {
|
[Symbol.iterator]() {
|
||||||
return this._values.entries();
|
return this._values.entries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
values() {
|
||||||
|
return this._values.values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
Reference in a new issue