Merge pull request #95 from vector-im/bwindels/e2ee-polish

E2EE polish
This commit is contained in:
Bruno Windels 2020-09-14 16:34:03 +00:00 committed by GitHub
commit ca6401ac46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 300 additions and 80 deletions

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 16 KiB

6
icon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="384" height="384" viewBox="0 0 384 384" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H384V384H0V0Z" fill="white"/>
<rect width="384" height="384" fill="#0DBD8B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.1212 192C64.1212 121.375 121.375 64.1212 192 64.1212C219.695 64.1212 245.333 72.9249 266.269 87.8864C264.331 92.1063 263.25 96.8018 263.25 101.75C263.25 120.113 278.136 135 296.5 135C299.74 135 302.871 134.536 305.832 133.672C314.811 151.161 319.879 170.989 319.879 192C319.879 262.626 262.626 319.879 192 319.879C121.375 319.879 64.1212 262.626 64.1212 192ZM320.589 124.669C331.148 144.792 337.121 167.698 337.121 192C337.121 272.148 272.148 337.121 192 337.121C111.852 337.121 46.8792 272.148 46.8792 192C46.8792 111.852 111.852 46.8792 192 46.8792C223.884 46.8792 253.367 57.1615 277.313 74.5912C282.733 70.7544 289.353 68.4998 296.5 68.4998C314.863 68.4998 329.75 83.3863 329.75 101.75C329.75 110.634 326.266 118.704 320.589 124.669Z" fill="white"/>
<circle cx="192" cy="192" r="85.5" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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;
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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;

View file

@ -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."`;
}
}
}

View file

@ -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`;
}
}

View file

@ -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}"`
}
}

View file

@ -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
}

View file

@ -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;

View file

@ -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,
});
},

View file

@ -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<DeviceSection>>}
*/
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse) {
const curve25519Keys = new Set();
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {

View file

@ -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();
}
}

View file

@ -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();

View file

@ -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;
}

View file

@ -93,4 +93,8 @@ export class EventEntry extends BaseEntry {
setDecryptionError(err) {
this._decryptionError = err;
}
get decryptionError() {
return this._decryptionError;
}
}

View file

@ -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) {

View file

@ -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));
}
}

View file

@ -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;

View file

@ -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);
}
});

View file

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