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"}, [