diff --git a/icon.png b/icon.png
index 39f1ae92..3d96b2d8 100644
Binary files a/icon.png and b/icon.png differ
diff --git a/icon.svg b/icon.svg
new file mode 100644
index 00000000..15dd4941
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/scripts/build.mjs b/scripts/build.mjs
index c6c37b96..7084ebc0 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -64,6 +64,9 @@ const olmFiles = {
wasmBundle: "olm-1421970081.js",
};
+// IDEA: how about instead of assetPaths we maintain a mapping between the source file and the target file
+// so throughout the build script we can refer to files by their source name
+
async function build() {
// only used for CSS for now, using legacy for all targets for now
const legacy = true;
@@ -80,7 +83,7 @@ async function build() {
await removeDirIfExists(targetDir);
await createDirs(targetDir, themes);
// copy assets
- await copyFolder(path.join(projectDir, "lib/olm/"), targetDir, );
+ await copyFolder(path.join(projectDir, "lib/olm/"), targetDir);
// also creates the directories where the theme css bundles are placed in,
// so do it first
const themeAssets = await copyThemeAssets(themes, legacy);
@@ -88,9 +91,19 @@ async function build() {
const jsLegacyBundlePath = await buildJsLegacy("src/main.js", `${PROJECT_ID}-legacy.js`);
const jsWorkerPath = await buildWorkerJsLegacy("src/worker.js", `worker.js`);
const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
- const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets);
let manifestPath;
+ // copy icons
+ let iconPng = await fs.readFile(path.join(projectDir, "icon.png"));
+ let iconPngPath = resource("icon.png", iconPng);
+ await fs.writeFile(iconPngPath, iconPng);
+ let iconSvg = await fs.readFile(path.join(projectDir, "icon.svg"));
+ let iconSvgPath = resource("icon.svg", iconSvg);
+ await fs.writeFile(iconSvgPath, iconSvg);
+
+ const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath,
+ iconPngPath, iconSvgPath, cssBundlePaths, themeAssets);
+
if (offline) {
manifestPath = await buildOffline(version, assetPaths);
}
@@ -99,7 +112,7 @@ async function build() {
console.log(`built ${PROJECT_ID} ${version} successfully`);
}
-function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets) {
+function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, iconPngPath, iconSvgPath, cssBundlePaths, themeAssets) {
function trim(path) {
if (!path.startsWith(targetDir)) {
throw new Error("invalid target path: " + targetDir);
@@ -113,7 +126,9 @@ function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBun
cssMainBundle: () => trim(cssBundlePaths.main),
cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)),
- otherAssets: () => Object.values(themeAssets).map(a => trim(a))
+ otherAssets: () => Object.values(themeAssets).map(a => trim(a)),
+ iconSvgPath: () => trim(iconSvgPath),
+ iconPngPath: () => trim(iconPngPath),
};
}
@@ -248,7 +263,8 @@ async function buildOffline(version, assetPaths) {
const offlineFiles = [
assetPaths.cssMainBundle(),
"index.html",
- "icon-192.png",
+ assetPaths.iconPngPath(),
+ assetPaths.iconSvgPath(),
].concat(assetPaths.cssThemeBundles());
// write appcache manifest
@@ -275,15 +291,15 @@ async function buildOffline(version, assetPaths) {
short_name: PROJECT_SHORT_NAME,
display: "fullscreen",
start_url: "index.html",
- icons: [{"src": "icon-192.png", "sizes": "192x192", "type": "image/png"}],
+ icons: [
+ {"src": assetPaths.iconPngPath(), "sizes": "384x384", "type": "image/png"},
+ {"src": assetPaths.iconSvgPath(), "type": "image/svg+xml"},
+ ],
+ theme_color: "#0DBD8B"
};
const manifestJson = JSON.stringify(webManifest);
const manifestPath = resource("manifest.json", manifestJson);
await fs.writeFile(manifestPath, manifestJson, "utf8");
- // copy icon
- // should this icon have a content hash as well?
- let icon = await fs.readFile(path.join(projectDir, "icon.png"));
- await fs.writeFile(path.join(targetDir, "icon-192.png"), icon);
return manifestPath;
}
diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js
index 15812f8c..7f973ad7 100644
--- a/src/domain/ViewModel.js
+++ b/src/domain/ViewModel.js
@@ -25,6 +25,7 @@ export class ViewModel extends EventEmitter {
constructor({clock, emitChange} = {}) {
super();
this.disposables = null;
+ this._isDisposed = false;
this._options = {clock, emitChange};
}
@@ -43,6 +44,11 @@ export class ViewModel extends EventEmitter {
if (this.disposables) {
this.disposables.dispose();
}
+ this._isDisposed = true;
+ }
+
+ get isDisposed() {
+ return this._isDisposed;
}
disposeTracked(disposable) {
diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js
index 6854bd5e..c2d9df5d 100644
--- a/src/domain/session/room/timeline/TilesCollection.js
+++ b/src/domain/session/room/timeline/TilesCollection.js
@@ -145,7 +145,7 @@ export class TilesCollection extends BaseObservableList {
if (tile) {
const action = tile.updateEntry(entry, params);
if (action.shouldReplace) {
- this._replaceTile(tileIdx, this._tileCreator(entry));
+ this._replaceTile(tileIdx, tile, this._tileCreator(entry));
}
if (action.shouldRemove) {
this._removeTile(tileIdx, tile);
@@ -167,7 +167,8 @@ export class TilesCollection extends BaseObservableList {
// merge with neighbours? ... hard to imagine use case for this ...
}
- _replaceTile(tileIdx, newTile) {
+ _replaceTile(tileIdx, existingTile, newTile) {
+ existingTile.dispose();
const prevTile = this._getTileAtIdx(tileIdx - 1);
const nextTile = this._getTileAtIdx(tileIdx + 1);
this._tiles[tileIdx] = newTile;
@@ -184,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
this._tiles.splice(tileIdx, 1);
prevTile && prevTile.updateNextSibling(nextTile);
nextTile && nextTile.updatePreviousSibling(prevTile);
- tile.setUpdateEmit(null);
+ tile.dispose();
this.emitRemove(tileIdx, tile);
}
@@ -254,6 +255,8 @@ export function tests() {
updateEntry() {
return UpdateAction.Nothing;
}
+
+ dispose() {}
}
return {
diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js
index 16d529cb..0f7464f8 100644
--- a/src/domain/session/room/timeline/TimelineViewModel.js
+++ b/src/domain/session/room/timeline/TimelineViewModel.js
@@ -50,9 +50,13 @@ export class TimelineViewModel extends ViewModel {
* @return {bool} startReached if the start of the timeline was reached
*/
async loadAtTop() {
+ if (this.isDisposed) {
+ // stop loading more, we switched room
+ return true;
+ }
const firstTile = this._tiles.getFirst();
if (firstTile.shape === "gap") {
- return firstTile.fill();
+ return await firstTile.fill();
} else {
await this._timeline.loadAtTop(10);
return false;
diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
index 99c8a291..bc4f8feb 100644
--- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
+++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
@@ -28,7 +28,17 @@ export class EncryptedEventTile extends MessageTile {
}
}
+ get shape() {
+ return "message-status"
+ }
+
get text() {
- return this.i18n`**Encrypted message**`;
+ const decryptionError = this._entry.decryptionError;
+ const code = decryptionError?.code;
+ if (code === "MEGOLM_NO_SESSION") {
+ return this.i18n`The sender hasn't sent us the key for this message yet.`;
+ } else {
+ return decryptionError?.message || this.i18n`"Could not decrypt message because of unknown reason."`;
+ }
}
}
diff --git a/src/domain/session/room/timeline/tiles/EncryptionEnabledTile.js b/src/domain/session/room/timeline/tiles/EncryptionEnabledTile.js
new file mode 100644
index 00000000..00bc6737
--- /dev/null
+++ b/src/domain/session/room/timeline/tiles/EncryptionEnabledTile.js
@@ -0,0 +1,28 @@
+/*
+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 {SimpleTile} from "./SimpleTile.js";
+
+export class EncryptionEnabledTile extends SimpleTile {
+ get shape() {
+ return "announcement";
+ }
+
+ get announcement() {
+ const senderName = this._entry.displayName || this._entry.sender;
+ return this.i18n`${senderName} has enabled end-to-end encryption`;
+ }
+}
diff --git a/src/domain/session/room/timeline/tiles/RoomNameTile.js b/src/domain/session/room/timeline/tiles/RoomNameTile.js
index a7a785d0..d5694a62 100644
--- a/src/domain/session/room/timeline/tiles/RoomNameTile.js
+++ b/src/domain/session/room/timeline/tiles/RoomNameTile.js
@@ -24,6 +24,6 @@ export class RoomNameTile extends SimpleTile {
get announcement() {
const content = this._entry.content;
- return `${this._entry.displayName || this._entry.sender} named the room "${content.name}"`
+ return `${this._entry.displayName || this._entry.sender} named the room "${content?.name}"`
}
}
diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js
index 7b018b37..12acd4c5 100644
--- a/src/domain/session/room/timeline/tiles/SimpleTile.js
+++ b/src/domain/session/room/timeline/tiles/SimpleTile.js
@@ -48,7 +48,13 @@ export class SimpleTile extends ViewModel {
}
// TilesCollection contract below
setUpdateEmit(emitUpdate) {
- this.updateOptions({emitChange: paramName => emitUpdate(this, paramName)});
+ this.updateOptions({emitChange: paramName => {
+ if (emitUpdate) {
+ emitUpdate(this, paramName);
+ } else {
+ console.trace("Tile is emitting event after being disposed");
+ }
+ }});
}
get upperEntry() {
@@ -88,5 +94,10 @@ export class SimpleTile extends ViewModel {
updateNextSibling(next) {
}
+
+ dispose() {
+ this.setUpdateEmit(null);
+ super.dispose();
+ }
// TilesCollection contract above
}
diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js
index 567e9e7d..5f5593d5 100644
--- a/src/domain/session/room/timeline/tilesCreator.js
+++ b/src/domain/session/room/timeline/tilesCreator.js
@@ -21,6 +21,7 @@ import {LocationTile} from "./tiles/LocationTile.js";
import {RoomNameTile} from "./tiles/RoomNameTile.js";
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
+import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
export function tilesCreator({room, ownUserId, clock}) {
return function tilesCreator(entry, emitUpdate) {
@@ -53,6 +54,8 @@ export function tilesCreator({room, ownUserId, clock}) {
return new RoomMemberTile(options);
case "m.room.encrypted":
return new EncryptedEventTile(options);
+ case "m.room.encryption":
+ return new EncryptionEnabledTile(options);
default:
// unknown type not rendered
return null;
diff --git a/src/main.js b/src/main.js
index 8a7cbf0d..97c0b812 100644
--- a/src/main.js
+++ b/src/main.js
@@ -105,6 +105,7 @@ export async function main(container, paths) {
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
const storageFactory = new StorageFactory();
+ const olmPromise = loadOlm(paths.olm);
// if wasm is not supported, we'll want
// to run some olm operations in a worker (mainly for IE11)
let workerPromise;
@@ -121,7 +122,7 @@ export async function main(container, paths) {
sessionInfoStorage,
request,
clock,
- olmPromise: loadOlm(paths.olm),
+ olmPromise,
workerPromise,
});
},
diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js
index aef62e10..cc3a4215 100644
--- a/src/matrix/e2ee/DeviceTracker.js
+++ b/src/matrix/e2ee/DeviceTracker.js
@@ -149,36 +149,17 @@ export class DeviceTracker {
}).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);
+ const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => {
+ const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity);
+ return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn);
}));
+ deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []);
} catch (err) {
txn.abort();
throw err;
@@ -187,6 +168,46 @@ export class DeviceTracker {
return deviceIdentities;
}
+ async _storeQueriedDevicesForUserId(userId, deviceIdentities, txn) {
+ const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId);
+ // delete any devices that we know off but are not in the response anymore.
+ // important this happens before checking if the ed25519 key changed,
+ // otherwise we would end up deleting existing devices with changed keys.
+ for (const deviceId of knownDeviceIds) {
+ if (deviceIdentities.every(di => di.deviceId !== deviceId)) {
+ txn.deviceIdentities.remove(userId, deviceId);
+ }
+ }
+
+ // all the device identities as we will have them in storage
+ const allDeviceIdentities = [];
+ const deviceIdentitiesToStore = [];
+ // filter out devices that have changed their ed25519 key since last time we queried them
+ deviceIdentities = await Promise.all(deviceIdentities.map(async deviceIdentity => {
+ if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
+ const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
+ if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
+ allDeviceIdentities.push(existingDevice);
+ }
+ }
+ allDeviceIdentities.push(deviceIdentity);
+ deviceIdentitiesToStore.push(deviceIdentity);
+ }));
+ // store devices
+ for (const deviceIdentity of deviceIdentitiesToStore) {
+ txn.deviceIdentities.set(deviceIdentity);
+ }
+ // mark user identities as up to date
+ const identity = await txn.userIdentities.get(userId);
+ identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
+ txn.userIdentities.set(identity);
+
+ return allDeviceIdentities;
+ }
+
+ /**
+ * @return {Array<{userId, verifiedKeys: Array>}
+ */
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse) {
const curve25519Keys = new Set();
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
diff --git a/src/matrix/e2ee/megolm/Decryption.js b/src/matrix/e2ee/megolm/Decryption.js
index b3f1ea71..4d756dcb 100644
--- a/src/matrix/e2ee/megolm/Decryption.js
+++ b/src/matrix/e2ee/megolm/Decryption.js
@@ -138,12 +138,23 @@ export class Decryption {
return;
}
- // TODO: compare first_known_index to see which session to keep
- const hasSession = await txn.inboundGroupSessions.has(roomId, senderKey, sessionId);
- if (!hasSession) {
- const session = new this._olm.InboundGroupSession();
- try {
- session.create(sessionKey);
+ const session = new this._olm.InboundGroupSession();
+ try {
+ session.create(sessionKey);
+
+ let incomingSessionIsBetter = true;
+ const existingSessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
+ if (existingSessionEntry) {
+ const existingSession = new this._olm.InboundGroupSession();
+ try {
+ existingSession.unpickle(this._pickleKey, existingSessionEntry.session);
+ incomingSessionIsBetter = session.first_known_index() < existingSession.first_known_index();
+ } finally {
+ existingSession.free();
+ }
+ }
+
+ if (incomingSessionIsBetter) {
const sessionEntry = {
roomId,
senderKey,
@@ -153,9 +164,9 @@ export class Decryption {
};
txn.inboundGroupSessions.set(sessionEntry);
newSessions.push(sessionEntry);
- } finally {
- session.free();
}
+ } finally {
+ session.free();
}
}
diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js
index 1ea18b4e..5976242d 100644
--- a/src/matrix/room/Room.js
+++ b/src/matrix/room/Room.js
@@ -68,11 +68,17 @@ export class Room extends EventEmitter {
}
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
await decryptRequest.complete();
- if (this._timeline) {
- // only adds if already present
- this._timeline.replaceEntries(retryEntries);
+
+ this._timeline?.replaceEntries(retryEntries);
+ // we would ideally write the room summary in the same txn as the groupSessionDecryptions in the
+ // _decryptEntries entries and could even know which events have been decrypted for the first
+ // time from DecryptionChanges.write and only pass those to the summary. As timeline changes
+ // are not essential to the room summary, it's fine to write this in a separate txn for now.
+ const changes = this._summary.processTimelineEntries(retryEntries, false, this._isTimelineOpen);
+ if (changes) {
+ this._summary.writeAndApplyChanges(changes, this._storage);
+ this._emitUpdate();
}
- // pass decryptedEntries to roomSummary
}
}
}
@@ -168,13 +174,14 @@ export class Room extends EventEmitter {
}
const summaryChanges = this._summary.writeSync(
roomResponse,
+ entries,
membership,
isInitialSync, this._isTimelineOpen,
txn);
// fetch new members while we have txn open,
// but don't make any in-memory changes yet
let heroChanges;
- if (needsHeroes(summaryChanges)) {
+ if (summaryChanges && needsHeroes(summaryChanges)) {
// room name disappeared, open heroes
if (!this._heroes) {
this._heroes = new Heroes(this._roomId);
@@ -231,8 +238,7 @@ export class Room extends EventEmitter {
}
}
if (emitChange) {
- this.emit("change");
- this._emitCollectionChange(this);
+ this._emitUpdate();
}
if (this._timeline) {
this._timeline.appendLiveEntries(newTimelineEntries);
@@ -442,6 +448,13 @@ export class Room extends EventEmitter {
return !!this._timeline;
}
+ _emitUpdate() {
+ // once for event emitter listeners
+ this.emit("change");
+ // and once for collection listeners
+ this._emitCollectionChange(this);
+ }
+
async clearUnread() {
if (this.isUnread || this.notificationCount) {
const txn = await this._storage.readWriteTxn([
@@ -456,8 +469,7 @@ export class Room extends EventEmitter {
}
await txn.complete();
this._summary.applyChanges(data);
- this.emit("change");
- this._emitCollectionChange(this);
+ this._emitUpdate();
try {
const lastEventId = await this._getLastEventId();
diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js
index 270fa690..741b8764 100644
--- a/src/matrix/room/RoomSummary.js
+++ b/src/matrix/room/RoomSummary.js
@@ -16,7 +16,19 @@ limitations under the License.
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
-function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
+
+function applyTimelineEntries(data, timelineEntries, isInitialSync, isTimelineOpen, ownUserId) {
+ if (timelineEntries.length) {
+ data = timelineEntries.reduce((data, entry) => {
+ return processTimelineEvent(data, entry,
+ isInitialSync, isTimelineOpen, ownUserId);
+ }, data);
+ }
+ return data;
+}
+
+
+function applySyncResponse(data, roomResponse, membership) {
if (roomResponse.summary) {
data = updateSummary(data, roomResponse.summary);
}
@@ -32,14 +44,15 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime
data = roomResponse.state.events.reduce(processStateEvent, data);
}
const {timeline} = roomResponse;
+ // process state events in timeline
+ // non-state events are handled by applyTimelineEntries
+ // so decryption is handled properly
if (timeline && Array.isArray(timeline.events)) {
data = timeline.events.reduce((data, event) => {
if (typeof event.state_key === "string") {
return processStateEvent(data, event);
- } else {
- return processTimelineEvent(data, event,
- isInitialSync, isTimelineOpen, ownUserId);
}
+ return data;
}, data);
}
const unreadNotifications = roomResponse.unread_notifications;
@@ -91,17 +104,21 @@ function processStateEvent(data, event) {
return data;
}
-function processTimelineEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) {
- if (event.type === "m.room.message") {
- data = data.cloneIfNeeded();
- data.lastMessageTimestamp = event.origin_server_ts;
- if (!isInitialSync && event.sender !== ownUserId && !isTimelineOpen) {
+function processTimelineEvent(data, eventEntry, isInitialSync, isTimelineOpen, ownUserId) {
+ if (eventEntry.eventType === "m.room.message") {
+ if (!data.lastMessageTimestamp || eventEntry.timestamp > data.lastMessageTimestamp) {
+ data = data.cloneIfNeeded();
+ data.lastMessageTimestamp = eventEntry.timestamp;
+ }
+ if (!isInitialSync && eventEntry.sender !== ownUserId && !isTimelineOpen) {
+ data = data.cloneIfNeeded();
data.isUnread = true;
}
- const {content} = event;
+ const {content} = eventEntry;
const body = content?.body;
const msgtype = content?.msgtype;
- if (msgtype === "m.text") {
+ if (msgtype === "m.text" && !eventEntry.isEncrypted) {
+ data = data.cloneIfNeeded();
data.lastMessageBody = body;
}
}
@@ -267,13 +284,34 @@ export class RoomSummary {
return data;
}
- writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
+ /**
+ * after retrying decryption
+ */
+ processTimelineEntries(timelineEntries, isInitialSync, isTimelineOpen) {
+ // clear cloned flag, so cloneIfNeeded makes a copy and
+ // this._data is not modified if any field is changed.
+
+ processTimelineEvent
+
+ this._data.cloned = false;
+ const data = applyTimelineEntries(
+ this._data,
+ timelineEntries,
+ isInitialSync, isTimelineOpen,
+ this._ownUserId);
+ if (data !== this._data) {
+ return data;
+ }
+ }
+
+ writeSync(roomResponse, timelineEntries, membership, isInitialSync, isTimelineOpen, txn) {
// clear cloned flag, so cloneIfNeeded makes a copy and
// this._data is not modified if any field is changed.
this._data.cloned = false;
- const data = applySyncResponse(
- this._data, roomResponse,
- membership,
+ let data = applySyncResponse(this._data, roomResponse, membership);
+ data = applyTimelineEntries(
+ data,
+ timelineEntries,
isInitialSync, isTimelineOpen,
this._ownUserId);
if (data !== this._data) {
@@ -282,6 +320,25 @@ export class RoomSummary {
}
}
+ /**
+ * Only to be used with processTimelineEntries,
+ * other methods like writeSync, writeHasFetchedMembers,
+ * writeIsTrackingMembers, ... take a txn directly.
+ */
+ async writeAndApplyChanges(data, storage) {
+ const txn = await storage.readTxn([
+ storage.storeNames.roomSummary,
+ ]);
+ try {
+ txn.roomSummary.set(data.serialize());
+ } catch (err) {
+ txn.abort();
+ throw err;
+ }
+ await txn.complete();
+ this.applyChanges(data);
+ }
+
applyChanges(data) {
this._data = data;
}
diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js
index 8c7029d4..81e31112 100644
--- a/src/matrix/room/timeline/entries/EventEntry.js
+++ b/src/matrix/room/timeline/entries/EventEntry.js
@@ -93,4 +93,8 @@ export class EventEntry extends BaseEntry {
setDecryptionError(err) {
this._decryptionError = err;
}
+
+ get decryptionError() {
+ return this._decryptionError;
+ }
}
diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js
index af6d49ca..d28d802f 100644
--- a/src/matrix/storage/idb/Transaction.js
+++ b/src/matrix/storage/idb/Transaction.js
@@ -36,12 +36,7 @@ export class Transaction {
constructor(txn, allowedStoreNames) {
this._txn = txn;
this._allowedStoreNames = allowedStoreNames;
- this._stores = {
- session: null,
- roomSummary: null,
- roomTimeline: null,
- roomState: null,
- };
+ this._stores = {};
}
_idbStore(name) {
diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.js b/src/matrix/storage/idb/stores/DeviceIdentityStore.js
index d3aba963..4d209532 100644
--- a/src/matrix/storage/idb/stores/DeviceIdentityStore.js
+++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.js
@@ -18,6 +18,11 @@ function encodeKey(userId, deviceId) {
return `${userId}|${deviceId}`;
}
+function decodeKey(key) {
+ const [userId, deviceId] = key.split("|");
+ return {userId, deviceId};
+}
+
export class DeviceIdentityStore {
constructor(store) {
this._store = store;
@@ -30,6 +35,21 @@ export class DeviceIdentityStore {
});
}
+ async getAllDeviceIds(userId) {
+ const deviceIds = [];
+ const range = IDBKeyRange.lowerBound(encodeKey(userId, ""));
+ await this._store.iterateKeys(range, key => {
+ const decodedKey = decodeKey(key);
+ // prevent running into the next room
+ if (decodedKey.userId === userId) {
+ deviceIds.push(decodedKey.deviceId);
+ return false; // fetch more
+ }
+ return true; // done
+ });
+ return deviceIds;
+ }
+
get(userId, deviceId) {
return this._store.get(encodeKey(userId, deviceId));
}
@@ -42,4 +62,8 @@ export class DeviceIdentityStore {
getByCurve25519Key(curve25519Key) {
return this._store.index("byCurve25519Key").get(curve25519Key);
}
+
+ remove(userId, deviceId) {
+ this._store.delete(encodeKey(userId, deviceId));
+ }
}
diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css
index bdcd599f..a669d3f5 100644
--- a/src/ui/web/css/themes/element/theme.css
+++ b/src/ui/web/css/themes/element/theme.css
@@ -327,6 +327,11 @@ ul.Timeline > li.continuation time {
display: none;
}
+ul.Timeline > li.messageStatus .message-container > p {
+ font-style: italic;
+ color: #777;
+}
+
.message-container {
padding: 1px 10px 0px 10px;
margin: 5px 10px 0 10px;
diff --git a/src/ui/web/session/room/TimelineList.js b/src/ui/web/session/room/TimelineList.js
index b43fcc27..2072b453 100644
--- a/src/ui/web/session/room/TimelineList.js
+++ b/src/ui/web/session/room/TimelineList.js
@@ -30,7 +30,9 @@ export class TimelineList extends ListView {
switch (entry.shape) {
case "gap": return new GapView(entry);
case "announcement": return new AnnouncementView(entry);
- case "message": return new TextMessageView(entry);
+ case "message":
+ case "message-status":
+ return new TextMessageView(entry);
case "image": return new ImageView(entry);
}
});
diff --git a/src/ui/web/session/room/timeline/common.js b/src/ui/web/session/room/timeline/common.js
index 7869731f..50b6a0cd 100644
--- a/src/ui/web/session/room/timeline/common.js
+++ b/src/ui/web/session/room/timeline/common.js
@@ -24,6 +24,7 @@ export function renderMessage(t, vm, children) {
pending: vm.isPending,
unverified: vm.isUnverified,
continuation: vm => vm.isContinuation,
+ messageStatus: vm => vm.shape === "message-status",
};
const profile = t.div({className: "profile"}, [