forked from mystiq/hydrogen-web
commit
ca6401ac46
22 changed files with 300 additions and 80 deletions
BIN
icon.png
BIN
icon.png
Binary file not shown.
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 16 KiB |
6
icon.svg
Normal file
6
icon.svg
Normal 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 |
|
@ -64,6 +64,9 @@ const olmFiles = {
|
||||||
wasmBundle: "olm-1421970081.js",
|
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() {
|
async function build() {
|
||||||
// only used for CSS for now, using legacy for all targets for now
|
// only used for CSS for now, using legacy for all targets for now
|
||||||
const legacy = true;
|
const legacy = true;
|
||||||
|
@ -80,7 +83,7 @@ async function build() {
|
||||||
await removeDirIfExists(targetDir);
|
await removeDirIfExists(targetDir);
|
||||||
await createDirs(targetDir, themes);
|
await createDirs(targetDir, themes);
|
||||||
// copy assets
|
// 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,
|
// also creates the directories where the theme css bundles are placed in,
|
||||||
// so do it first
|
// so do it first
|
||||||
const themeAssets = await copyThemeAssets(themes, legacy);
|
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 jsLegacyBundlePath = await buildJsLegacy("src/main.js", `${PROJECT_ID}-legacy.js`);
|
||||||
const jsWorkerPath = await buildWorkerJsLegacy("src/worker.js", `worker.js`);
|
const jsWorkerPath = await buildWorkerJsLegacy("src/worker.js", `worker.js`);
|
||||||
const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
|
const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
|
||||||
const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets);
|
|
||||||
|
|
||||||
let manifestPath;
|
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) {
|
if (offline) {
|
||||||
manifestPath = await buildOffline(version, assetPaths);
|
manifestPath = await buildOffline(version, assetPaths);
|
||||||
}
|
}
|
||||||
|
@ -99,7 +112,7 @@ async function build() {
|
||||||
console.log(`built ${PROJECT_ID} ${version} successfully`);
|
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) {
|
function trim(path) {
|
||||||
if (!path.startsWith(targetDir)) {
|
if (!path.startsWith(targetDir)) {
|
||||||
throw new Error("invalid target path: " + targetDir);
|
throw new Error("invalid target path: " + targetDir);
|
||||||
|
@ -113,7 +126,9 @@ function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBun
|
||||||
cssMainBundle: () => trim(cssBundlePaths.main),
|
cssMainBundle: () => trim(cssBundlePaths.main),
|
||||||
cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
|
cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
|
||||||
cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)),
|
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 = [
|
const offlineFiles = [
|
||||||
assetPaths.cssMainBundle(),
|
assetPaths.cssMainBundle(),
|
||||||
"index.html",
|
"index.html",
|
||||||
"icon-192.png",
|
assetPaths.iconPngPath(),
|
||||||
|
assetPaths.iconSvgPath(),
|
||||||
].concat(assetPaths.cssThemeBundles());
|
].concat(assetPaths.cssThemeBundles());
|
||||||
|
|
||||||
// write appcache manifest
|
// write appcache manifest
|
||||||
|
@ -275,15 +291,15 @@ async function buildOffline(version, assetPaths) {
|
||||||
short_name: PROJECT_SHORT_NAME,
|
short_name: PROJECT_SHORT_NAME,
|
||||||
display: "fullscreen",
|
display: "fullscreen",
|
||||||
start_url: "index.html",
|
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 manifestJson = JSON.stringify(webManifest);
|
||||||
const manifestPath = resource("manifest.json", manifestJson);
|
const manifestPath = resource("manifest.json", manifestJson);
|
||||||
await fs.writeFile(manifestPath, manifestJson, "utf8");
|
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;
|
return manifestPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ export class ViewModel extends EventEmitter {
|
||||||
constructor({clock, emitChange} = {}) {
|
constructor({clock, emitChange} = {}) {
|
||||||
super();
|
super();
|
||||||
this.disposables = null;
|
this.disposables = null;
|
||||||
|
this._isDisposed = false;
|
||||||
this._options = {clock, emitChange};
|
this._options = {clock, emitChange};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,6 +44,11 @@ export class ViewModel extends EventEmitter {
|
||||||
if (this.disposables) {
|
if (this.disposables) {
|
||||||
this.disposables.dispose();
|
this.disposables.dispose();
|
||||||
}
|
}
|
||||||
|
this._isDisposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDisposed() {
|
||||||
|
return this._isDisposed;
|
||||||
}
|
}
|
||||||
|
|
||||||
disposeTracked(disposable) {
|
disposeTracked(disposable) {
|
||||||
|
|
|
@ -145,7 +145,7 @@ export class TilesCollection extends BaseObservableList {
|
||||||
if (tile) {
|
if (tile) {
|
||||||
const action = tile.updateEntry(entry, params);
|
const action = tile.updateEntry(entry, params);
|
||||||
if (action.shouldReplace) {
|
if (action.shouldReplace) {
|
||||||
this._replaceTile(tileIdx, this._tileCreator(entry));
|
this._replaceTile(tileIdx, tile, this._tileCreator(entry));
|
||||||
}
|
}
|
||||||
if (action.shouldRemove) {
|
if (action.shouldRemove) {
|
||||||
this._removeTile(tileIdx, tile);
|
this._removeTile(tileIdx, tile);
|
||||||
|
@ -167,7 +167,8 @@ export class TilesCollection extends BaseObservableList {
|
||||||
// merge with neighbours? ... hard to imagine use case for this ...
|
// 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 prevTile = this._getTileAtIdx(tileIdx - 1);
|
||||||
const nextTile = this._getTileAtIdx(tileIdx + 1);
|
const nextTile = this._getTileAtIdx(tileIdx + 1);
|
||||||
this._tiles[tileIdx] = newTile;
|
this._tiles[tileIdx] = newTile;
|
||||||
|
@ -184,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
|
||||||
this._tiles.splice(tileIdx, 1);
|
this._tiles.splice(tileIdx, 1);
|
||||||
prevTile && prevTile.updateNextSibling(nextTile);
|
prevTile && prevTile.updateNextSibling(nextTile);
|
||||||
nextTile && nextTile.updatePreviousSibling(prevTile);
|
nextTile && nextTile.updatePreviousSibling(prevTile);
|
||||||
tile.setUpdateEmit(null);
|
tile.dispose();
|
||||||
this.emitRemove(tileIdx, tile);
|
this.emitRemove(tileIdx, tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,6 +255,8 @@ export function tests() {
|
||||||
updateEntry() {
|
updateEntry() {
|
||||||
return UpdateAction.Nothing;
|
return UpdateAction.Nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -50,9 +50,13 @@ export class TimelineViewModel extends ViewModel {
|
||||||
* @return {bool} startReached if the start of the timeline was reached
|
* @return {bool} startReached if the start of the timeline was reached
|
||||||
*/
|
*/
|
||||||
async loadAtTop() {
|
async loadAtTop() {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
// stop loading more, we switched room
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const firstTile = this._tiles.getFirst();
|
const firstTile = this._tiles.getFirst();
|
||||||
if (firstTile.shape === "gap") {
|
if (firstTile.shape === "gap") {
|
||||||
return firstTile.fill();
|
return await firstTile.fill();
|
||||||
} else {
|
} else {
|
||||||
await this._timeline.loadAtTop(10);
|
await this._timeline.loadAtTop(10);
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -28,7 +28,17 @@ export class EncryptedEventTile extends MessageTile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get shape() {
|
||||||
|
return "message-status"
|
||||||
|
}
|
||||||
|
|
||||||
get text() {
|
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."`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,6 @@ export class RoomNameTile extends SimpleTile {
|
||||||
|
|
||||||
get announcement() {
|
get announcement() {
|
||||||
const content = this._entry.content;
|
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}"`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,13 @@ export class SimpleTile extends ViewModel {
|
||||||
}
|
}
|
||||||
// TilesCollection contract below
|
// TilesCollection contract below
|
||||||
setUpdateEmit(emitUpdate) {
|
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() {
|
get upperEntry() {
|
||||||
|
@ -88,5 +94,10 @@ export class SimpleTile extends ViewModel {
|
||||||
updateNextSibling(next) {
|
updateNextSibling(next) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.setUpdateEmit(null);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
// TilesCollection contract above
|
// TilesCollection contract above
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {LocationTile} from "./tiles/LocationTile.js";
|
||||||
import {RoomNameTile} from "./tiles/RoomNameTile.js";
|
import {RoomNameTile} from "./tiles/RoomNameTile.js";
|
||||||
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
||||||
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
||||||
|
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
||||||
|
|
||||||
export function tilesCreator({room, ownUserId, clock}) {
|
export function tilesCreator({room, ownUserId, clock}) {
|
||||||
return function tilesCreator(entry, emitUpdate) {
|
return function tilesCreator(entry, emitUpdate) {
|
||||||
|
@ -53,6 +54,8 @@ export function tilesCreator({room, ownUserId, clock}) {
|
||||||
return new RoomMemberTile(options);
|
return new RoomMemberTile(options);
|
||||||
case "m.room.encrypted":
|
case "m.room.encrypted":
|
||||||
return new EncryptedEventTile(options);
|
return new EncryptedEventTile(options);
|
||||||
|
case "m.room.encryption":
|
||||||
|
return new EncryptionEnabledTile(options);
|
||||||
default:
|
default:
|
||||||
// unknown type not rendered
|
// unknown type not rendered
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -105,6 +105,7 @@ export async function main(container, paths) {
|
||||||
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
|
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
|
||||||
const storageFactory = new StorageFactory();
|
const storageFactory = new StorageFactory();
|
||||||
|
|
||||||
|
const olmPromise = loadOlm(paths.olm);
|
||||||
// if wasm is not supported, we'll want
|
// if wasm is not supported, we'll want
|
||||||
// to run some olm operations in a worker (mainly for IE11)
|
// to run some olm operations in a worker (mainly for IE11)
|
||||||
let workerPromise;
|
let workerPromise;
|
||||||
|
@ -121,7 +122,7 @@ export async function main(container, paths) {
|
||||||
sessionInfoStorage,
|
sessionInfoStorage,
|
||||||
request,
|
request,
|
||||||
clock,
|
clock,
|
||||||
olmPromise: loadOlm(paths.olm),
|
olmPromise,
|
||||||
workerPromise,
|
workerPromise,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -149,36 +149,17 @@ export class DeviceTracker {
|
||||||
}).response();
|
}).response();
|
||||||
|
|
||||||
const verifiedKeysPerUser = this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"]);
|
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([
|
const txn = await this._storage.readWriteTxn([
|
||||||
this._storage.storeNames.userIdentities,
|
this._storage.storeNames.userIdentities,
|
||||||
this._storage.storeNames.deviceIdentities,
|
this._storage.storeNames.deviceIdentities,
|
||||||
]);
|
]);
|
||||||
let deviceIdentities;
|
let deviceIdentities;
|
||||||
try {
|
try {
|
||||||
// check ed25519 key has not changed if we've seen the device before
|
const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => {
|
||||||
deviceIdentities = await Promise.all(deviceIdentitiesWithPossibleChangedKeys.map(async (deviceIdentity) => {
|
const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity);
|
||||||
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn);
|
||||||
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);
|
|
||||||
}));
|
}));
|
||||||
|
deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
txn.abort();
|
txn.abort();
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -187,6 +168,46 @@ export class DeviceTracker {
|
||||||
return deviceIdentities;
|
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) {
|
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse) {
|
||||||
const curve25519Keys = new Set();
|
const curve25519Keys = new Set();
|
||||||
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
|
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
|
||||||
|
|
|
@ -138,12 +138,23 @@ export class Decryption {
|
||||||
return;
|
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();
|
const session = new this._olm.InboundGroupSession();
|
||||||
try {
|
try {
|
||||||
session.create(sessionKey);
|
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 = {
|
const sessionEntry = {
|
||||||
roomId,
|
roomId,
|
||||||
senderKey,
|
senderKey,
|
||||||
|
@ -153,10 +164,10 @@ export class Decryption {
|
||||||
};
|
};
|
||||||
txn.inboundGroupSessions.set(sessionEntry);
|
txn.inboundGroupSessions.set(sessionEntry);
|
||||||
newSessions.push(sessionEntry);
|
newSessions.push(sessionEntry);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
session.free();
|
session.free();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
// this will be passed to the Room in notifyRoomKeys
|
// this will be passed to the Room in notifyRoomKeys
|
||||||
|
|
|
@ -68,11 +68,17 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
|
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
|
||||||
await decryptRequest.complete();
|
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(
|
const summaryChanges = this._summary.writeSync(
|
||||||
roomResponse,
|
roomResponse,
|
||||||
|
entries,
|
||||||
membership,
|
membership,
|
||||||
isInitialSync, this._isTimelineOpen,
|
isInitialSync, this._isTimelineOpen,
|
||||||
txn);
|
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;
|
||||||
if (needsHeroes(summaryChanges)) {
|
if (summaryChanges && needsHeroes(summaryChanges)) {
|
||||||
// room name disappeared, open heroes
|
// room name disappeared, open heroes
|
||||||
if (!this._heroes) {
|
if (!this._heroes) {
|
||||||
this._heroes = new Heroes(this._roomId);
|
this._heroes = new Heroes(this._roomId);
|
||||||
|
@ -231,8 +238,7 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (emitChange) {
|
if (emitChange) {
|
||||||
this.emit("change");
|
this._emitUpdate();
|
||||||
this._emitCollectionChange(this);
|
|
||||||
}
|
}
|
||||||
if (this._timeline) {
|
if (this._timeline) {
|
||||||
this._timeline.appendLiveEntries(newTimelineEntries);
|
this._timeline.appendLiveEntries(newTimelineEntries);
|
||||||
|
@ -442,6 +448,13 @@ export class Room extends EventEmitter {
|
||||||
return !!this._timeline;
|
return !!this._timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_emitUpdate() {
|
||||||
|
// once for event emitter listeners
|
||||||
|
this.emit("change");
|
||||||
|
// and once for collection listeners
|
||||||
|
this._emitCollectionChange(this);
|
||||||
|
}
|
||||||
|
|
||||||
async clearUnread() {
|
async clearUnread() {
|
||||||
if (this.isUnread || this.notificationCount) {
|
if (this.isUnread || this.notificationCount) {
|
||||||
const txn = await this._storage.readWriteTxn([
|
const txn = await this._storage.readWriteTxn([
|
||||||
|
@ -456,8 +469,7 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
await txn.complete();
|
await txn.complete();
|
||||||
this._summary.applyChanges(data);
|
this._summary.applyChanges(data);
|
||||||
this.emit("change");
|
this._emitUpdate();
|
||||||
this._emitCollectionChange(this);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lastEventId = await this._getLastEventId();
|
const lastEventId = await this._getLastEventId();
|
||||||
|
|
|
@ -16,7 +16,19 @@ limitations under the License.
|
||||||
|
|
||||||
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
|
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) {
|
if (roomResponse.summary) {
|
||||||
data = updateSummary(data, 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);
|
data = roomResponse.state.events.reduce(processStateEvent, data);
|
||||||
}
|
}
|
||||||
const {timeline} = roomResponse;
|
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)) {
|
if (timeline && Array.isArray(timeline.events)) {
|
||||||
data = timeline.events.reduce((data, event) => {
|
data = timeline.events.reduce((data, event) => {
|
||||||
if (typeof event.state_key === "string") {
|
if (typeof event.state_key === "string") {
|
||||||
return processStateEvent(data, event);
|
return processStateEvent(data, event);
|
||||||
} else {
|
|
||||||
return processTimelineEvent(data, event,
|
|
||||||
isInitialSync, isTimelineOpen, ownUserId);
|
|
||||||
}
|
}
|
||||||
|
return data;
|
||||||
}, data);
|
}, data);
|
||||||
}
|
}
|
||||||
const unreadNotifications = roomResponse.unread_notifications;
|
const unreadNotifications = roomResponse.unread_notifications;
|
||||||
|
@ -91,17 +104,21 @@ function processStateEvent(data, event) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processTimelineEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) {
|
function processTimelineEvent(data, eventEntry, isInitialSync, isTimelineOpen, ownUserId) {
|
||||||
if (event.type === "m.room.message") {
|
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 = data.cloneIfNeeded();
|
||||||
data.lastMessageTimestamp = event.origin_server_ts;
|
|
||||||
if (!isInitialSync && event.sender !== ownUserId && !isTimelineOpen) {
|
|
||||||
data.isUnread = true;
|
data.isUnread = true;
|
||||||
}
|
}
|
||||||
const {content} = event;
|
const {content} = eventEntry;
|
||||||
const body = content?.body;
|
const body = content?.body;
|
||||||
const msgtype = content?.msgtype;
|
const msgtype = content?.msgtype;
|
||||||
if (msgtype === "m.text") {
|
if (msgtype === "m.text" && !eventEntry.isEncrypted) {
|
||||||
|
data = data.cloneIfNeeded();
|
||||||
data.lastMessageBody = body;
|
data.lastMessageBody = body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -267,13 +284,34 @@ export class RoomSummary {
|
||||||
return data;
|
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
|
// 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.
|
||||||
this._data.cloned = false;
|
this._data.cloned = false;
|
||||||
const data = applySyncResponse(
|
let data = applySyncResponse(this._data, roomResponse, membership);
|
||||||
this._data, roomResponse,
|
data = applyTimelineEntries(
|
||||||
membership,
|
data,
|
||||||
|
timelineEntries,
|
||||||
isInitialSync, isTimelineOpen,
|
isInitialSync, isTimelineOpen,
|
||||||
this._ownUserId);
|
this._ownUserId);
|
||||||
if (data !== this._data) {
|
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) {
|
applyChanges(data) {
|
||||||
this._data = data;
|
this._data = data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,4 +93,8 @@ export class EventEntry extends BaseEntry {
|
||||||
setDecryptionError(err) {
|
setDecryptionError(err) {
|
||||||
this._decryptionError = err;
|
this._decryptionError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get decryptionError() {
|
||||||
|
return this._decryptionError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,12 +36,7 @@ export class Transaction {
|
||||||
constructor(txn, allowedStoreNames) {
|
constructor(txn, allowedStoreNames) {
|
||||||
this._txn = txn;
|
this._txn = txn;
|
||||||
this._allowedStoreNames = allowedStoreNames;
|
this._allowedStoreNames = allowedStoreNames;
|
||||||
this._stores = {
|
this._stores = {};
|
||||||
session: null,
|
|
||||||
roomSummary: null,
|
|
||||||
roomTimeline: null,
|
|
||||||
roomState: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_idbStore(name) {
|
_idbStore(name) {
|
||||||
|
|
|
@ -18,6 +18,11 @@ function encodeKey(userId, deviceId) {
|
||||||
return `${userId}|${deviceId}`;
|
return `${userId}|${deviceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeKey(key) {
|
||||||
|
const [userId, deviceId] = key.split("|");
|
||||||
|
return {userId, deviceId};
|
||||||
|
}
|
||||||
|
|
||||||
export class DeviceIdentityStore {
|
export class DeviceIdentityStore {
|
||||||
constructor(store) {
|
constructor(store) {
|
||||||
this._store = 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) {
|
get(userId, deviceId) {
|
||||||
return this._store.get(encodeKey(userId, deviceId));
|
return this._store.get(encodeKey(userId, deviceId));
|
||||||
}
|
}
|
||||||
|
@ -42,4 +62,8 @@ export class DeviceIdentityStore {
|
||||||
getByCurve25519Key(curve25519Key) {
|
getByCurve25519Key(curve25519Key) {
|
||||||
return this._store.index("byCurve25519Key").get(curve25519Key);
|
return this._store.index("byCurve25519Key").get(curve25519Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove(userId, deviceId) {
|
||||||
|
this._store.delete(encodeKey(userId, deviceId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -327,6 +327,11 @@ ul.Timeline > li.continuation time {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.Timeline > li.messageStatus .message-container > p {
|
||||||
|
font-style: italic;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
.message-container {
|
.message-container {
|
||||||
padding: 1px 10px 0px 10px;
|
padding: 1px 10px 0px 10px;
|
||||||
margin: 5px 10px 0 10px;
|
margin: 5px 10px 0 10px;
|
||||||
|
|
|
@ -30,7 +30,9 @@ export class TimelineList extends ListView {
|
||||||
switch (entry.shape) {
|
switch (entry.shape) {
|
||||||
case "gap": return new GapView(entry);
|
case "gap": return new GapView(entry);
|
||||||
case "announcement": return new AnnouncementView(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);
|
case "image": return new ImageView(entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,6 +24,7 @@ export function renderMessage(t, vm, children) {
|
||||||
pending: vm.isPending,
|
pending: vm.isPending,
|
||||||
unverified: vm.isUnverified,
|
unverified: vm.isUnverified,
|
||||||
continuation: vm => vm.isContinuation,
|
continuation: vm => vm.isContinuation,
|
||||||
|
messageStatus: vm => vm.shape === "message-status",
|
||||||
};
|
};
|
||||||
|
|
||||||
const profile = t.div({className: "profile"}, [
|
const profile = t.div({className: "profile"}, [
|
||||||
|
|
Loading…
Reference in a new issue