From b83613924c4061d9ebf72c515bc3c95a7730e3eb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 09:25:56 +0200 Subject: [PATCH 001/213] don't assume there is at least 1 tile before loading at top it can happen that all tiles are not renderable, and we should just keep calling loadAtTop --- src/domain/session/room/timeline/TimelineViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 63791fa3..d91b9acb 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -52,7 +52,7 @@ export class TimelineViewModel extends ViewModel { return true; } const firstTile = this._tiles.getFirst(); - if (firstTile.shape === "gap") { + if (firstTile?.shape === "gap") { return await firstTile.fill(); } else { const topReached = await this._timeline.loadAtTop(10); From a39d26a3e084bd09189e0a7394b19112255da634 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 10:11:50 +0000 Subject: [PATCH 002/213] clarify browser support --- doc/FAQ.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/FAQ.md b/doc/FAQ.md index bd5da95b..c7222907 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -1,5 +1,15 @@ # FAQ +## What browsers are supported? + +Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Safari (have not tested in 12 and below) and any mobile versions of these. It will probably also work on any derivatives of these. + +1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported. + +TorBrowser ships a crippled IndexedDB implementation and will not work. At some point we should support a memory store as a fallback, but that will still give a sub-par experience with e2ee. + +It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore. + ## Is there a way to run the app as a desktop app? You can install Hydrogen as a PWA using Chrome/Chromium on any platform or Edge on Windows. Gnome Web/Ephiphany also allows to "Install site as web application". There is no Electron build of Hydrogen, and there will likely be none in the near future, as Electron complicates the release process considerably. Once Hydrogen is more mature and feature complete, we might reconsider and use [Tauri](https://tauri.studio) if there are compelling use cases not possible with PWAs. For now though, we want to keep development and releasing fast and nimble ;) From eec7ceb76506a5365f36b43f9f15465fed15f2e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 10:13:37 +0000 Subject: [PATCH 003/213] remove not being able to leave rooms --- doc/FAQ.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index c7222907..f72c7f42 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -22,10 +22,6 @@ If you can't find an easy way to locate the feature you are looking for, then th That's not yet a feature, as hydrogen just uses a single line text box for message input for now. -### Hmm does Hydrogen not support leaving rooms? I left some rooms via Element and they moved to "Historical" but nothing happened on this end. - -Indeed :) [Joining](https://github.com/vector-im/hydrogen-web/issues/28) and [leaving](https://github.com/vector-im/hydrogen-web/issues/147) isn't implemented yet, just haven't gotten around to it. - ## How can I verify my session from Element? You can only verify by comparing keys manually currently. In Element, go to your own profile in the right panel, click on the Hydrogen device and select Manually Verify by Text. The session key displayed should be the same as in the Hydrogen settings. You can't yet mark your Element session as trusted from Hydrogen. From ecde6ed91983b0c166df3dd21bf1cee1437191c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 10:14:58 +0000 Subject: [PATCH 004/213] Update FAQ.md --- doc/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index f72c7f42..e8be2a15 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -2,7 +2,7 @@ ## What browsers are supported? -Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Safari (have not tested in 12 and below) and any mobile versions of these. It will probably also work on any derivatives of these. +Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Safari [1] and any mobile versions of these. It will probably also work on any derivatives of these. 1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported. From 09cfd2a10a471faa7a23dd9100fbdf9d6fcb39cd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 10:15:35 +0000 Subject: [PATCH 005/213] Update FAQ.md --- doc/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index e8be2a15..97b0ce29 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -6,7 +6,7 @@ Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Safari 1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported. -TorBrowser ships a crippled IndexedDB implementation and will not work. At some point we should support a memory store as a fallback, but that will still give a sub-par experience with e2ee. +TorBrowser ships a crippled IndexedDB implementation and will not work. At some point we should support a memory store as a fallback, but that will still give a sub-par experience with end-to-end encryption. It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore. From 934839574e13519b29254ba27f0810cd8eb98c09 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 10:16:12 +0000 Subject: [PATCH 006/213] Update FAQ.md --- doc/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index 97b0ce29..4921616e 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -2,7 +2,7 @@ ## What browsers are supported? -Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Safari [1] and any mobile versions of these. It will probably also work on any derivatives of these. +Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Edge [1], Safari [1] and any mobile versions of these. It will probably also work on any derivatives of these. 1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported. From 46bfab3eb7423498304c3b6f3437aa9eaea014ca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:49:35 +0200 Subject: [PATCH 007/213] fix some lint and comment --- src/matrix/room/timeline/entries/EventEntry.js | 1 + src/matrix/storage/idb/stores/RoomStateStore.js | 4 ---- src/mocks/poll.js | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 4dbb352f..95440471 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -122,6 +122,7 @@ export class EventEntry extends BaseEventEntry { if (redactionEvent) { return redactionEvent.content?.reason; } + // fall back to local echo reason return super.redactionReason; } } \ No newline at end of file diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 73bde1ec..99fc23f7 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -26,10 +26,6 @@ export class RoomStateStore { this._roomStateStore = idbStore; } - getAllForType(roomId, type) { - throw new Error("unimplemented"); - } - get(roomId, type, stateKey) { const key = encodeKey(roomId, type, stateKey); return this._roomStateStore.get(key); diff --git a/src/mocks/poll.js b/src/mocks/poll.js index 40348bb3..aaadaa27 100644 --- a/src/mocks/poll.js +++ b/src/mocks/poll.js @@ -15,7 +15,6 @@ limitations under the License. */ export async function poll(fn) { - let result; do { const result = fn(); if (result) { From 36a35d92f044a1b4b40f10803345bb2c47d3f935 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:42:09 +0200 Subject: [PATCH 008/213] pass ownUserId to RelationWriter We'll need to to aggregate whether we have reacted to a message Create writers at room level and pass subwriter is dependency, rather than creating them in sync and gap writer. --- src/matrix/room/BaseRoom.js | 7 +++++++ src/matrix/room/Room.js | 14 +++++++++++++- src/matrix/room/timeline/persistence/GapWriter.js | 5 ++--- .../room/timeline/persistence/RelationWriter.js | 3 ++- src/matrix/room/timeline/persistence/SyncWriter.js | 8 +++----- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 531f6a1a..9269404a 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -17,6 +17,7 @@ limitations under the License. import {EventEmitter} from "../../utils/EventEmitter.js"; import {RoomSummary} from "./RoomSummary.js"; import {GapWriter} from "./timeline/persistence/GapWriter.js"; +import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; import {Timeline} from "./timeline/Timeline.js"; import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {WrappedError} from "../error.js" @@ -266,10 +267,16 @@ export class BaseRoom extends EventEmitter { // detect remote echos of pending messages in the gap extraGapFillChanges = await this._writeGapFill(response.chunk, txn, log); // write new events into gap + const relationWriter = new RelationWriter({ + roomId: this._roomId, + fragmentIdComparer: this._fragmentIdComparer, + ownUserId: this._user.id, + }); const gapWriter = new GapWriter({ roomId: this._roomId, storage: this._storage, fragmentIdComparer: this._fragmentIdComparer, + relationWriter }); gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log); } catch (err) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index da9eef52..0361e069 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -16,6 +16,8 @@ limitations under the License. import {BaseRoom} from "./BaseRoom.js"; import {SyncWriter} from "./timeline/persistence/SyncWriter.js"; +import {MemberWriter} from "./timeline/persistence/MemberWriter.js"; +import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" import {Heroes} from "./members/Heroes.js"; @@ -28,7 +30,17 @@ export class Room extends BaseRoom { constructor(options) { super(options); const {pendingEvents} = options; - this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer}); + const relationWriter = new RelationWriter({ + roomId: this.id, + fragmentIdComparer: this._fragmentIdComparer, + ownUserId: this._user.id + }); + this._syncWriter = new SyncWriter({ + roomId: this.id, + fragmentIdComparer: this._fragmentIdComparer, + relationWriter, + memberWriter: new MemberWriter(this.id) + }); this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents}); } diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 67668298..5e49695a 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -14,18 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RelationWriter} from "./RelationWriter.js"; import {EventKey} from "../EventKey.js"; import {EventEntry} from "../entries/EventEntry.js"; import {createEventEntry, directionalAppend} from "./common.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js"; export class GapWriter { - constructor({roomId, storage, fragmentIdComparer}) { + constructor({roomId, storage, fragmentIdComparer, relationWriter}) { this._roomId = roomId; this._storage = storage; this._fragmentIdComparer = fragmentIdComparer; - this._relationWriter = new RelationWriter(roomId, fragmentIdComparer); + this._relationWriter = relationWriter; } // events is in reverse-chronological order (last event comes at index 0) if backwards async _findOverlappingEvents(fragmentEntry, events, txn, log) { diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 45716d04..305fc8eb 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -18,8 +18,9 @@ import {EventEntry} from "../entries/EventEntry.js"; import {REDACTION_TYPE} from "../../common.js"; export class RelationWriter { - constructor(roomId, fragmentIdComparer) { + constructor({roomId, ownUserId, fragmentIdComparer}) { this._roomId = roomId; + this._ownUserId = ownUserId; this._fragmentIdComparer = fragmentIdComparer; } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 671b944a..39d341ef 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -20,8 +20,6 @@ import {EventEntry} from "../entries/EventEntry.js"; import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {createEventEntry} from "./common.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js"; -import {MemberWriter} from "./MemberWriter.js"; -import {RelationWriter} from "./RelationWriter.js"; // Synapse bug? where the m.room.create event appears twice in sync response // when first syncing the room @@ -38,10 +36,10 @@ function deduplicateEvents(events) { } export class SyncWriter { - constructor({roomId, fragmentIdComparer}) { + constructor({roomId, fragmentIdComparer, memberWriter, relationWriter}) { this._roomId = roomId; - this._memberWriter = new MemberWriter(roomId); - this._relationWriter = new RelationWriter(roomId, fragmentIdComparer); + this._memberWriter = memberWriter; + this._relationWriter = relationWriter; this._fragmentIdComparer = fragmentIdComparer; this._lastLiveKey = null; } From 41fb30c68b32d2ac0740bce5e94e0821a490aae3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:44:35 +0200 Subject: [PATCH 009/213] add relations store --- src/matrix/Sync.js | 1 + src/matrix/room/BaseRoom.js | 1 + src/matrix/storage/common.js | 1 + src/matrix/storage/idb/Transaction.js | 5 ++ src/matrix/storage/idb/schema.js | 6 ++ .../idb/stores/TimelineRelationStore.js | 62 +++++++++++++++++++ 6 files changed, 76 insertions(+) create mode 100644 src/matrix/storage/idb/stores/TimelineRelationStore.js diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 62bb67bd..ed9889b1 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -333,6 +333,7 @@ export class Sync { storeNames.roomState, storeNames.roomMembers, storeNames.timelineEvents, + storeNames.timelineRelations, storeNames.timelineFragments, storeNames.pendingEvents, storeNames.userIdentities, diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 9269404a..8df281fd 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -259,6 +259,7 @@ export class BaseRoom extends EventEmitter { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.pendingEvents, this._storage.storeNames.timelineEvents, + this._storage.storeNames.timelineRelations, this._storage.storeNames.timelineFragments, ]); let extraGapFillChanges; diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index bd477cbd..4d10ef65 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -22,6 +22,7 @@ export const STORE_NAMES = Object.freeze([ "invites", "roomMembers", "timelineEvents", + "timelineRelations", "timelineFragments", "pendingEvents", "userIdentities", diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index a2041d31..bdcc45e3 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -21,6 +21,7 @@ import {SessionStore} from "./stores/SessionStore.js"; import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; import {InviteStore} from "./stores/InviteStore.js"; import {TimelineEventStore} from "./stores/TimelineEventStore.js"; +import {TimelineRelationStore} from "./stores/TimelineRelationStore.js"; import {RoomStateStore} from "./stores/RoomStateStore.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; @@ -82,6 +83,10 @@ export class Transaction { return this._store("timelineEvents", idbStore => new TimelineEventStore(idbStore)); } + get timelineRelations() { + return this._store("timelineRelations", idbStore => new TimelineRelationStore(idbStore)); + } + get roomState() { return this._store("roomState", idbStore => new RoomStateStore(idbStore)); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 3af31d8d..256b6732 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -16,6 +16,7 @@ export const schema = [ createInviteStore, createArchivedRoomSummaryStore, migrateOperationScopeIndex, + createTimelineRelationsStore, ]; // TODO: how to deal with git merge conflicts of this array? @@ -135,4 +136,9 @@ async function migrateOperationScopeIndex(db, txn) { txn.abort(); console.error("could not migrate operations", err.stack); } +} + +//v10 +function createTimelineRelationsStore(db) { + db.createObjectStore("timelineRelations", {keyPath: ""}); } \ No newline at end of file diff --git a/src/matrix/storage/idb/stores/TimelineRelationStore.js b/src/matrix/storage/idb/stores/TimelineRelationStore.js new file mode 100644 index 00000000..504693f9 --- /dev/null +++ b/src/matrix/storage/idb/stores/TimelineRelationStore.js @@ -0,0 +1,62 @@ +/* +Copyright 2021 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 {MIN_UNICODE, MAX_UNICODE} from "./common.js"; + +function encodeKey(roomId, targetEventId, relType, sourceEventId) { + return `${roomId}|${targetEventId}|${relType}|${sourceEventId}`; +} + +function decodeKey(key) { + const [roomId, targetEventId, relType, sourceEventId] = key.split("|"); + return {roomId, targetEventId, relType, sourceEventId}; +} + +export class TimelineRelationStore { + constructor(store) { + this._store = store; + } + + add(roomId, targetEventId, relType, sourceEventId) { + return this._store.add(encodeKey(roomId, targetEventId, relType, sourceEventId)); + } + + remove(roomId, targetEventId, relType, sourceEventId) { + return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); + } + + removeAllForTarget(roomId, targetId) { + const range = this._store.IDBKeyRange.bound( + encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE), + encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE), + true, + true + ); + return this._store.delete(range); + } + + async getForTargetAndType(roomId, targetId, relType) { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = this._store.IDBKeyRange.bound( + encodeKey(roomId, targetId, relType, MIN_UNICODE), + encodeKey(roomId, targetId, relType, MAX_UNICODE), + true, + true + ); + const keys = await this._store.selectAll(range); + return keys.map(decodeKey); + } +} From a78e9af8fc8b19f2f4f465606f9ea68f9b2f87c1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:45:56 +0200 Subject: [PATCH 010/213] Support (de)aggregating annotation relations in relation writer When deaggregating on redacting an annotation relation, we remove the relation and aggregate the other relations for that key again, so we can reliably detect the first timestamp (and count and me as well to lesser extent). as a consequence, more than one event can get updated when redacting a relation (the relation is updated, as well as the relation target), so account for that by returning an array of entries that have updated. --- .../room/timeline/entries/EventEntry.js | 3 +- .../room/timeline/persistence/GapWriter.js | 6 +- .../timeline/persistence/RelationWriter.js | 163 +++++++++++++++--- .../room/timeline/persistence/SyncWriter.js | 6 +- src/matrix/room/timeline/relations.js | 47 +++++ 5 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 src/matrix/room/timeline/relations.js diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 95440471..b19415c5 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseEventEntry} from "./BaseEventEntry.js"; import {getPrevContentFromStateEvent} from "../../common.js"; +import {getRelatedEventId} from "../relations.js"; export class EventEntry extends BaseEventEntry { constructor(eventEntry, fragmentIdComparer) { @@ -110,7 +111,7 @@ export class EventEntry extends BaseEventEntry { } get relatedEventId() { - return this._eventEntry.event.redacts; + return getRelatedEventId(this.event); } get isRedacted() { diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 5e49695a..7b6e7600 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -122,9 +122,9 @@ export class GapWriter { txn.timelineEvents.insert(eventStorageEntry); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); directionalAppend(entries, eventEntry, direction); - const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log); - if (updatedRelationTargetEntry) { - updatedEntries.push(updatedRelationTargetEntry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(eventEntry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); } } return {entries, updatedEntries}; diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 305fc8eb..b15f2800 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -16,6 +16,7 @@ limitations under the License. import {EventEntry} from "../entries/EventEntry.js"; import {REDACTION_TYPE} from "../../common.js"; +import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js"; export class RelationWriter { constructor({roomId, ownUserId, fragmentIdComparer}) { @@ -26,49 +27,161 @@ export class RelationWriter { // this needs to happen again after decryption too for edits async writeRelation(sourceEntry, txn, log) { - if (sourceEntry.relatedEventId) { - const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); + const {relatedEventId} = sourceEntry; + if (relatedEventId) { + const relation = getRelation(sourceEntry.event); + if (relation) { + txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id); + } + const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId); if (target) { - if (this._applyRelation(sourceEntry, target, log)) { - txn.timelineEvents.update(target); - return new EventEntry(target, this._fragmentIdComparer); + const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log); + if (updatedStorageEntries) { + return updatedStorageEntries.map(e => { + txn.timelineEvents.update(e); + return new EventEntry(e, this._fragmentIdComparer); + }); } } } - return; + // TODO: check if sourceEntry is in timelineRelations as a target, and if so reaggregate it + return null; } - _applyRelation(sourceEntry, targetEntry, log) { + /** + * @param {EventEntry} sourceEntry + * @param {Object} targetStorageEntry event entry as stored in the timelineEvents store + * @return {[Object]} array of event storage entries that have been updated + * */ + async _applyRelation(sourceEntry, targetStorageEntry, txn, log) { if (sourceEntry.eventType === REDACTION_TYPE) { - return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log)); + return log.wrap("redact", async log => { + const redactedEvent = targetStorageEntry.event; + const relation = getRelation(redactedEvent); // get this before redacting + const redacted = this._applyRedaction(sourceEntry.event, targetStorageEntry, txn, log); + if (redacted) { + const updated = [targetStorageEntry]; + if (relation) { + const relationTargetStorageEntry = await this._reaggregateRelation(redactedEvent, relation, txn, log); + if (relationTargetStorageEntry) { + updated.push(relationTargetStorageEntry); + } + } + return updated; + } + return null; + }); } else { - return false; - } - } - - _applyRedaction(redactionEvent, targetEvent, log) { - log.set("redactionId", redactionEvent.event_id); - log.set("id", targetEvent.event_id); - // TODO: should we make efforts to preserve the decrypted event type? - // probably ok not to, as we'll show whatever is deleted as "deleted message" - // reactions are the only thing that comes to mind, but we don't encrypt those (for now) - for (const key of Object.keys(targetEvent)) { - if (!_REDACT_KEEP_KEY_MAP[key]) { - delete targetEvent[key]; + const relation = getRelation(sourceEntry.event); + if (relation) { + const relType = relation.rel_type; + if (relType === ANNOTATION_RELATION_TYPE) { + const aggregated = log.wrap("react", log => { + return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log); + }); + if (aggregated) { + return [targetStorageEntry]; + } + } } } - const {content} = targetEvent; - const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type]; + return null; + } + + _applyRedaction(redactionEvent, redactedStorageEntry, txn, log) { + const redactedEvent = redactedStorageEntry.event; + log.set("redactionId", redactionEvent.event_id); + log.set("id", redactedEvent.event_id); + + const relation = getRelation(redactedEvent); + if (relation) { + txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id); + } + // check if we're the target of a relation and remove all relations then as well + txn.timelineRelations.removeAllForTarget(this._roomId, redactedEvent.event_id); + + for (const key of Object.keys(redactedEvent)) { + if (!_REDACT_KEEP_KEY_MAP[key]) { + delete redactedEvent[key]; + } + } + const {content} = redactedEvent; + const keepMap = _REDACT_KEEP_CONTENT_MAP[redactedEvent.type]; for (const key of Object.keys(content)) { if (!keepMap?.[key]) { delete content[key]; } } - targetEvent.unsigned = targetEvent.unsigned || {}; - targetEvent.unsigned.redacted_because = redactionEvent; + redactedEvent.unsigned = redactedEvent.unsigned || {}; + redactedEvent.unsigned.redacted_because = redactionEvent; + + delete redactedStorageEntry.annotations; return true; } + + _aggregateAnnotation(annotationEvent, targetStorageEntry, log) { + // TODO: do we want to verify it is a m.reaction event somehow? + const relation = getRelation(annotationEvent); + if (!relation) { + return false; + } + + let {annotations} = targetStorageEntry; + if (!annotations) { + targetStorageEntry.annotations = annotations = {}; + } + let annotation = annotations[relation.key]; + if (!annotation) { + annotations[relation.key] = annotation = { + count: 0, + me: false, + firstTimestamp: Number.MAX_SAFE_INTEGER + }; + } + const sentByMe = annotationEvent.sender === this._ownUserId; + + annotation.me = annotation.me || sentByMe; + annotation.count += 1; + annotation.firstTimestamp = Math.min( + annotation.firstTimestamp, + annotationEvent.origin_server_ts + ); + + return true; + } + + async _reaggregateRelation(redactedRelationEvent, redactedRelation, txn, log) { + if (redactedRelation.rel_type === ANNOTATION_RELATION_TYPE) { + return log.wrap("reaggregate annotations", log => this._reaggregateAnnotation( + redactedRelation.event_id, + redactedRelation.key, + txn, log + )); + } + return null; + } + + async _reaggregateAnnotation(targetId, key, txn, log) { + const target = await txn.timelineEvents.getByEventId(this._roomId, targetId); + if (!target) { + return null; + } + log.set("id", targetId); + const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE); + log.set("relations", relations.length); + delete target.annotations[key]; + await Promise.all(relations.map(async relation => { + const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId); + if (!annotation) { + log.log({l: "missing annotation", id: relation.sourceEventId}); + } + if (getRelation(annotation.event).key === key) { + this._aggregateAnnotation(annotation.event, target, log); + } + })); + return target; + } } // copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 39d341ef..96551056 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -172,9 +172,9 @@ export class SyncWriter { txn.timelineEvents.insert(storageEntry); const entry = new EventEntry(storageEntry, this._fragmentIdComparer); entries.push(entry); - const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log); - if (updatedRelationTargetEntry) { - updatedEntries.push(updatedRelationTargetEntry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); } // update state events after writing event, so for a member event, // we only update the member info after having written the member event diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js new file mode 100644 index 00000000..ed9da586 --- /dev/null +++ b/src/matrix/room/timeline/relations.js @@ -0,0 +1,47 @@ +/* +Copyright 2021 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 {REDACTION_TYPE} from "../common.js"; + +export const REACTION_TYPE = "m.reaction"; +export const ANNOTATION_RELATION_TYPE = "m.annotation"; + +export function createAnnotation(targetId, key) { + return { + "m.relates_to": { + "event_id": targetId, + key, + "rel_type": ANNOTATION_RELATION_TYPE + } + }; +} + +export function getRelatedEventId(event) { + if (event.type === REDACTION_TYPE) { + return event.redacts; + } else { + const relation = getRelation(event); + if (relation) { + return relation.event_id; + } + } + return null; +} + +export function getRelation(event) { + return event.content?.["m.relates_to"]; +} + From b05345ee2780b550174f95421115260f09b0bf6d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:48:42 +0200 Subject: [PATCH 011/213] only show redacted messages --- src/domain/session/room/timeline/tilesCreator.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index af91cac7..4926ff6a 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -32,13 +32,14 @@ export function tilesCreator(baseOptions) { const options = Object.assign({entry, emitUpdate}, baseOptions); if (entry.isGap) { return new GapTile(options); - } else if (entry.isRedacted) { - return new RedactedTile(options); } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { return new MissingAttachmentTile(options); } else if (entry.eventType) { switch (entry.eventType) { case "m.room.message": { + if (entry.isRedacted) { + return new RedactedTile(options); + } const content = entry.content; const msgtype = content && content.msgtype; switch (msgtype) { From b94ab42c90eddd62ffb95a2fb4957cf5588a2ca7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:10:29 +0200 Subject: [PATCH 012/213] delete annotations object when no more annotations left --- .../room/timeline/persistence/RelationWriter.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index b15f2800..3838c494 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -171,6 +171,9 @@ export class RelationWriter { const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE); log.set("relations", relations.length); delete target.annotations[key]; + if (isObjectEmpty(target.annotations)) { + delete target.annotations; + } await Promise.all(relations.map(async relation => { const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId); if (!annotation) { @@ -184,6 +187,15 @@ export class RelationWriter { } } +function isObjectEmpty(obj) { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + return false; + } + } + return true; +} + // copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd /* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted * From 2152d5e833b67ecfec850d1db4177bf407becdf3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:13:13 +0200 Subject: [PATCH 013/213] expose reactions on base message tile as vm with observable list --- .../room/timeline/ReactionsViewModel.js | 92 +++++++++++++++++++ .../room/timeline/tiles/BaseMessageTile.js | 38 ++++++++ .../room/timeline/entries/EventEntry.js | 4 + src/observable/map/ObservableMap.js | 4 + 4 files changed, 138 insertions(+) create mode 100644 src/domain/session/room/timeline/ReactionsViewModel.js diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js new file mode 100644 index 00000000..cdb98dcd --- /dev/null +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -0,0 +1,92 @@ +/* +Copyright 2021 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 {ViewModel} from "../../../ViewModel.js"; +import {ObservableMap} from "../../../../observable/map/ObservableMap.js"; + +export class ReactionsViewModel extends ViewModel { + constructor(parentEntry) { + super(); + this._parentEntry = parentEntry; + this._map = new ObservableMap(); + this._reactions = this._map.sortValues((a, b) => a._compare(b)); + } + + update(annotations) { + for (const key in annotations) { + if (annotations.hasOwnProperty(key)) { + const annotation = annotations[key]; + const reaction = this._map.get(key); + if (reaction) { + if (reaction._tryUpdate(annotation)) { + this._map.update(key); + } + } else { + this._map.add(key, new ReactionViewModel(key, annotation, this._parentEntry)); + } + } + } + for (const existingKey of this._map.keys()) { + if (!annotations.hasOwnProperty(existingKey)) { + this._map.remove(existingKey); + } + } + } + + get reactions() { + return this._reactions; + } +} + +class ReactionViewModel extends ViewModel { + constructor(key, annotation, parentEntry) { + super(); + this._key = key; + this._annotation = annotation; + this._parentEntry = parentEntry; + } + + _tryUpdate(annotation) { + if ( + annotation.me !== this._annotation.me || + annotation.count !== this._annotation.count || + annotation.firstTimestamp !== this._annotation.firstTimestamp + ) { + this._annotation = annotation; + return true; + } + return false; + } + + get key() { + return this._key; + } + + get count() { + return this._annotation.count; + } + + get haveReacted() { + return this._annotation.me; + } + + _compare(other) { + return this._annotation.count - other._annotation.count; + } + + react() { + return this._parentEntry.react(this.key); + } +} \ No newline at end of file diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 704cccb8..b03e2fee 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -15,6 +15,7 @@ limitations under the License. */ import {SimpleTile} from "./SimpleTile.js"; +import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js"; export class BaseMessageTile extends SimpleTile { @@ -22,6 +23,10 @@ export class BaseMessageTile extends SimpleTile { super(options); this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; + this._reactions = null; + if (this._entry.annotations) { + this._updateReactions(); + } } get _room() { @@ -97,6 +102,14 @@ export class BaseMessageTile extends SimpleTile { } } + updateEntry(entry, param) { + const action = super.updateEntry(entry, param); + if (action.shouldUpdate) { + this._updateReactions(); + } + return action; + } + redact(reason, log) { return this._room.sendRedaction(this._entry.id, reason, log); } @@ -104,4 +117,29 @@ export class BaseMessageTile extends SimpleTile { get canRedact() { return this._powerLevels.canRedactFromSender(this._entry.sender); } + + get reactions() { + return this._reactions; + } + + _updateReactions() { + const {annotations} = this._entry; + if (!annotations) { + if (this._reactions) { + this._reactions = null; + this.emitChange("reactions"); + } + } + let isNewMap = false; + if (!this._reactions) { + this._reactions = new ReactionsViewModel(this); + isNewMap = true; + } + + this._reactions.update(annotations); + + if (isNewMap) { + this.emitChange("reactions"); + } + } } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index b19415c5..8f532f02 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -126,4 +126,8 @@ export class EventEntry extends BaseEventEntry { // fall back to local echo reason return super.redactionReason; } + + get annotations() { + return this._eventEntry.annotations; + } } \ No newline at end of file diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.js index 7fe10d95..4e9df5bb 100644 --- a/src/observable/map/ObservableMap.js +++ b/src/observable/map/ObservableMap.js @@ -74,6 +74,10 @@ export class ObservableMap extends BaseObservableMap { values() { return this._values.values(); } + + keys() { + return this._values.keys(); + } } export function tests() { From b722691e85893b5958b18486d61220a762deb99d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:16:19 +0200 Subject: [PATCH 014/213] show reactions as ListView of buttons if present --- .../web/ui/css/themes/element/timeline.css | 14 ++++-- .../session/room/timeline/BaseMessageView.js | 2 + .../ui/session/room/timeline/ReactionsView.js | 43 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/platform/web/ui/session/room/timeline/ReactionsView.js diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index d7eac940..0e1e867a 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -20,7 +20,8 @@ limitations under the License. grid-template: "avatar sender" auto "avatar body" auto - "time body" 1fr / + "time body" 1fr + "time reactions" auto / 30px 1fr; column-gap: 8px; padding: 4px; @@ -37,9 +38,10 @@ limitations under the License. @media screen and (max-width: 800px) { .Timeline_message { grid-template: - "avatar sender" auto - "body body" 1fr - "time time" auto / + "avatar sender" auto + "body body" 1fr + "time time" auto + "reactions reactions" auto / 30px 1fr; } @@ -211,6 +213,10 @@ only loads when the top comes into view*/ color: #ff4b55; } +.Timeline_messageReactions { + grid-area: reactions; +} + .AnnouncementView { margin: 5px 0; padding: 5px 10%; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 60b39048..0c9c0c0e 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -20,6 +20,7 @@ import {tag} from "../../../general/html.js"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; +import {ReactionsView} from "./ReactionsView.js"; export class BaseMessageView extends TemplateView { constructor(value) { @@ -38,6 +39,7 @@ export class BaseMessageView extends TemplateView { this.renderMessageBody(t, vm), // should be after body as it is overlayed on top t.button({className: "Timeline_messageOptions"}, "⋯"), + t.ifView(vm => vm.reactions, vm => new ReactionsView(vm.reactions)), ]); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js new file mode 100644 index 00000000..aa717b94 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -0,0 +1,43 @@ +/* +Copyright 2021 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 {ListView} from "../../../general/ListView.js"; +import {TemplateView} from "../../../general/TemplateView.js"; + +export class ReactionsView extends ListView { + constructor(reactionsViewModel) { + const options = { + className: "Timeline_messageReactions", + list: reactionsViewModel.reactions, + onItemClick: (reactionView, evt) => reactionView.onClick(), + } + super(options, reactionVM => new ReactionView(reactionVM)); + } +} + +class ReactionView extends TemplateView { + render(t, vm) { + const haveReacted = vm => vm.haveReacted; + return t.button({ + disabled: haveReacted, + className: {haveReacted}, + }, [vm.key, " ", vm => `${vm.count}`]); + } + + onClick() { + this.value.react(); + } +} \ No newline at end of file From 20abb01ee8f2e2ec56a2b172c3b4418b31fc9faf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:16:53 +0200 Subject: [PATCH 015/213] very basic way of sending a reaction --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ++++ src/matrix/room/timeline/entries/BaseEventEntry.js | 7 ++++++- .../web/ui/session/room/timeline/BaseMessageView.js | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index b03e2fee..f480f0db 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -122,6 +122,10 @@ export class BaseMessageTile extends SimpleTile { return this._reactions; } + react(key) { + this._room.sendEvent("m.reaction", this._entry.annotate(key)); + } + _updateReactions() { const {annotations} = this._entry; if (!annotations) { diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 4bf09ed5..a036e8c6 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; +import {createAnnotation} from "../relations.js"; export class BaseEventEntry extends BaseEntry { constructor(fragmentIdComparer) { @@ -80,4 +81,8 @@ export class BaseEventEntry extends BaseEntry { // so don't clear _pendingRedactions here } } -} \ No newline at end of file + + annotate(key) { + return createAnnotation(this.id, key); + } +} diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 0c9c0c0e..2f41fa75 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -99,6 +99,7 @@ export class BaseMessageView extends TemplateView { } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } + options.push(Menu.option(vm.i18n`React with 👍`, () => vm.react("👍"))) return options; } From bb8acbefa38a8884d89b32b261eea641695aa8d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:57:16 +0200 Subject: [PATCH 016/213] support undoing a reaction --- .../session/room/timeline/ReactionsViewModel.js | 8 ++++++-- .../room/timeline/tiles/BaseMessageTile.js | 9 ++++++++- src/matrix/room/BaseRoom.js | 16 ++++++++++++++++ src/matrix/room/timeline/entries/EventEntry.js | 4 ++++ .../room/timeline/entries/PendingEventEntry.js | 5 +++++ .../web/ui/css/themes/element/timeline.css | 5 +++++ .../ui/session/room/timeline/ReactionsView.js | 3 +-- 7 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index cdb98dcd..9385664c 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -86,7 +86,11 @@ class ReactionViewModel extends ViewModel { return this._annotation.count - other._annotation.count; } - react() { - return this._parentEntry.react(this.key); + toggleReaction() { + if (this.haveReacted) { + return this._parentEntry.redactReaction(this.key); + } else { + return this._parentEntry.react(this.key); + } } } \ No newline at end of file diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index f480f0db..5cfd6239 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -123,7 +123,14 @@ export class BaseMessageTile extends SimpleTile { } react(key) { - this._room.sendEvent("m.reaction", this._entry.annotate(key)); + return this._room.sendEvent("m.reaction", this._entry.annotate(key)); + } + + async redactReaction(key) { + const id = await this._entry.getOwnAnnotationId(this._room, key); + if (id) { + this._room.sendRedaction(id); + } } _updateReactions() { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 8df281fd..2513f67b 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -28,6 +28,7 @@ import {EventEntry} from "./timeline/entries/EventEntry.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; import {DecryptionSource} from "../e2ee/common.js"; import {ensureLogItem} from "../../logging/utils.js"; +import {ANNOTATION_RELATION_TYPE, getRelation} from "./timeline/relations.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -451,6 +452,21 @@ export class BaseRoom extends EventEmitter { return observable; } + async getOwnAnnotationEventId(targetId, key) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineEvents, + this._storage.storeNames.timelineRelations, + ]); + const relations = await txn.timelineRelations.getForTargetAndType(this.id, targetId, ANNOTATION_RELATION_TYPE); + for (const relation of relations) { + const annotation = await txn.timelineEvents.getByEventId(this.id, relation.sourceEventId); + if (annotation.event.sender === this._user.id && getRelation(annotation.event).key === key) { + return annotation.event.event_id; + } + } + return null; + } + async _readEventById(eventId) { let stores = [this._storage.storeNames.timelineEvents]; if (this.isEncrypted) { diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 8f532f02..311cea8c 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -130,4 +130,8 @@ export class EventEntry extends BaseEventEntry { get annotations() { return this._eventEntry.annotations; } + + getOwnAnnotationId(room, key) { + return room.getOwnAnnotationEventId(this.id, key); + } } \ No newline at end of file diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 64771ffc..77f6da93 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -85,4 +85,9 @@ export class PendingEventEntry extends BaseEventEntry { get relatedEventId() { return this._pendingEvent.relatedEventId; } + + getOwnAnnotationId(_, key) { + // TODO: implement this once local reactions are implemented + return null; + } } diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 0e1e867a..1ae67d4a 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -217,6 +217,11 @@ only loads when the top comes into view*/ grid-area: reactions; } +.Timeline_messageReactions button.haveReacted { + background-color: green; + color: white; +} + .AnnouncementView { margin: 5px 0; padding: 5px 10%; diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index aa717b94..a0979186 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -32,12 +32,11 @@ class ReactionView extends TemplateView { render(t, vm) { const haveReacted = vm => vm.haveReacted; return t.button({ - disabled: haveReacted, className: {haveReacted}, }, [vm.key, " ", vm => `${vm.count}`]); } onClick() { - this.value.react(); + this.value.toggleReaction(); } } \ No newline at end of file From 2eb2e4e9b37543c52af71e406e91242b2341e37f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:57:29 +0200 Subject: [PATCH 017/213] more stable sorting order for reactions --- src/domain/session/room/timeline/ReactionsViewModel.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 9385664c..ba9b7eb4 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -83,7 +83,13 @@ class ReactionViewModel extends ViewModel { } _compare(other) { - return this._annotation.count - other._annotation.count; + const a = this._annotation; + const b = other._annotation; + if (a.count !== b.count) { + return b.count - a.count; + } else { + return a.firstTimestamp - b.firstTimestamp; + } } toggleReaction() { From 8d4d9c6e8d84f24e1b82f9b8870776466bd5cd4c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 19:57:48 +0200 Subject: [PATCH 018/213] WIP --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 5cfd6239..7742a7c5 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -122,6 +122,11 @@ export class BaseMessageTile extends SimpleTile { return this._reactions; } + get canReact() { + // TODO + return true; + } + react(key) { return this._room.sendEvent("m.reaction", this._entry.annotate(key)); } From eab3c2d6dd8978c38f759c70f6fd85e29952cc1f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:00:09 +0200 Subject: [PATCH 019/213] update relation notes --- doc/impl-thoughts/RELATIONS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/impl-thoughts/RELATIONS.md b/doc/impl-thoughts/RELATIONS.md index 00a7f609..5d91c28e 100644 --- a/doc/impl-thoughts/RELATIONS.md +++ b/doc/impl-thoughts/RELATIONS.md @@ -9,13 +9,12 @@ SyncWriter will need to resolve the related remote id to a [fragmentId, eventInd sourceEventId: targetEventId: rel_type: - type: roomId: } `{"key": "!bEWtlqtDwCLFIAKAcv:matrix.org|$apmyieZOI5vm4DzjEFzjbRiZW9oeQQR21adM6A6eRwM|m.annotation|m.reaction|$jSisozR3is5XUuDZXD5cyaVMOQ5_BtFS3jKfcP89MOM"}` -or actually stored like `roomId|targetEventId|rel_type|source_event_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event +or actually stored like `roomId|targetEventId|rel_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event We should look into what part of the relationships will be present on the event once it is received from the server (e.g. m.replace might be evident, but not all the reaction events?). If not, we could add a object store with missing relation targets. From cc444fa20725ee10fe88bb5fd687016fccc3f4d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:00:25 +0200 Subject: [PATCH 020/213] we actually don't need any of the view model infrastructure all the updates go over the observable list --- src/domain/session/room/timeline/ReactionsViewModel.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index ba9b7eb4..5f3b01a7 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -16,9 +16,8 @@ limitations under the License. import {ViewModel} from "../../../ViewModel.js"; import {ObservableMap} from "../../../../observable/map/ObservableMap.js"; -export class ReactionsViewModel extends ViewModel { +export class ReactionsViewModel { constructor(parentEntry) { - super(); this._parentEntry = parentEntry; this._map = new ObservableMap(); this._reactions = this._map.sortValues((a, b) => a._compare(b)); @@ -50,9 +49,8 @@ export class ReactionsViewModel extends ViewModel { } } -class ReactionViewModel extends ViewModel { +class ReactionViewModel { constructor(key, annotation, parentEntry) { - super(); this._key = key; this._annotation = annotation; this._parentEntry = parentEntry; From 1385a22e60a78ab868c4dbb2f924ed25e59f5de8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:00:57 +0200 Subject: [PATCH 021/213] don't recreate the reactions after clearing it with the last one removed --- .../room/timeline/tiles/BaseMessageTile.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 7742a7c5..e319e317 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -143,19 +143,12 @@ export class BaseMessageTile extends SimpleTile { if (!annotations) { if (this._reactions) { this._reactions = null; - this.emitChange("reactions"); } - } - let isNewMap = false; - if (!this._reactions) { - this._reactions = new ReactionsViewModel(this); - isNewMap = true; - } - - this._reactions.update(annotations); - - if (isNewMap) { - this.emitChange("reactions"); + } else { + if (!this._reactions) { + this._reactions = new ReactionsViewModel(this); + } + this._reactions.update(annotations); } } } From 3e2b7ba5fa79440d65f6a4ae0ea7c7d1d56ebbaa Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:01:26 +0200 Subject: [PATCH 022/213] obsolete, already provided in parent class --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index e319e317..719571a9 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -29,10 +29,6 @@ export class BaseMessageTile extends SimpleTile { } } - get _room() { - return this.getOption("room"); - } - get _mediaRepository() { return this._room.mediaRepository; } From d91282a7671f2a57124226cbd17d895a26e1adda Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:01:47 +0200 Subject: [PATCH 023/213] render reactions in div instead of ul --- src/platform/web/ui/general/ListView.js | 7 ++++--- src/platform/web/ui/session/room/timeline/ReactionsView.js | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 749ae6cf..3aed06eb 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag} from "./html.js"; +import {el} from "./html.js"; import {errorToDOM} from "./error.js"; function insertAt(parentNode, idx, childNode) { @@ -28,10 +28,11 @@ function insertAt(parentNode, idx, childNode) { } export class ListView { - constructor({list, onItemClick, className, parentProvidesUpdates = true}, childCreator) { + constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) { this._onItemClick = onItemClick; this._list = list; this._className = className; + this._tagName = tagName; this._root = null; this._subscription = null; this._childCreator = childCreator; @@ -62,7 +63,7 @@ export class ListView { if (this._className) { attr.className = this._className; } - this._root = tag.ul(attr); + this._root = el(this._tagName, attr); this.loadList(); if (this._onItemClick) { this._root.addEventListener("click", this._onClick); diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index a0979186..c1250683 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -21,6 +21,7 @@ export class ReactionsView extends ListView { constructor(reactionsViewModel) { const options = { className: "Timeline_messageReactions", + tagName: "div", list: reactionsViewModel.reactions, onItemClick: (reactionView, evt) => reactionView.onClick(), } From 05cc1f675773f8b5473dfd5329d25e74fdb2a06a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 21:01:59 +0200 Subject: [PATCH 024/213] make reactions look like element --- .../web/ui/css/themes/element/timeline.css | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 1ae67d4a..03cae338 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -215,11 +215,25 @@ only loads when the top comes into view*/ .Timeline_messageReactions { grid-area: reactions; + margin-top: 6px; +} + +.Timeline_messageReactions button { + display: inline-flex; + line-height: 2.0rem; + margin-right: 6px; + padding: 1px 6px; + border: 1px solid #e9edf1; + border-radius: 10px; + background-color: #f3f8fd; + cursor: pointer; + user-select: none; + vertical-align: middle; } .Timeline_messageReactions button.haveReacted { - background-color: green; - color: white; + background-color: #e9fff9; + border-color: #0DBD8B; } .AnnouncementView { From ff370d03dbdb6db072d143692c70ca4fbcdc5f67 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 09:37:36 +0200 Subject: [PATCH 025/213] catch errors thrown by childview mount method on add in ListView --- src/platform/web/ui/general/ListView.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 3aed06eb..398774ee 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -122,7 +122,13 @@ export class ListView { this.onBeforeListChanged(); const child = this._childCreator(value); this._childInstances.splice(idx, 0, child); - insertAt(this._root, idx, child.mount(this._mountArgs)); + let node; + try { + node = child.mount(this._mountArgs); + } catch (err) { + node = errorToDOM(err); + } + insertAt(this._root, idx, node); this.onListChanged(); } From dde26da5a6291fc013ac9c3a17ba63f5426588e0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 10:07:12 +0200 Subject: [PATCH 026/213] add mountView utility to handle error handling on mount and use it where errorToDOM is used currently for catching mount errors --- src/platform/web/ui/general/ListView.js | 17 +++---------- src/platform/web/ui/general/TemplateView.js | 12 +++------ src/platform/web/ui/general/utils.js | 27 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 23 deletions(-) create mode 100644 src/platform/web/ui/general/utils.js diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 398774ee..2e29996c 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {el} from "./html.js"; -import {errorToDOM} from "./error.js"; +import {mountView} from "./utils.js"; function insertAt(parentNode, idx, childNode) { const isLast = idx === parentNode.childElementCount; @@ -108,12 +108,7 @@ export class ListView { for (let item of this._list) { const child = this._childCreator(item); this._childInstances.push(child); - try { - const childDomNode = child.mount(this._mountArgs); - fragment.appendChild(childDomNode); - } catch (err) { - fragment.appendChild(errorToDOM(err)); - } + fragment.appendChild(mountView(child, this._mountArgs)); } this._root.appendChild(fragment); } @@ -122,13 +117,7 @@ export class ListView { this.onBeforeListChanged(); const child = this._childCreator(value); this._childInstances.splice(idx, 0, child); - let node; - try { - node = child.mount(this._mountArgs); - } catch (err) { - node = errorToDOM(err); - } - insertAt(this._root, idx, node); + insertAt(this._root, idx, mountView(child, this._mountArgs)); this.onListChanged(); } diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index f3425136..4b2bcf74 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -15,7 +15,7 @@ limitations under the License. */ import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; -import {errorToDOM} from "./error.js"; +import {mountView} from "./utils.js"; import {BaseUpdateView} from "./BaseUpdateView.js"; function objHasFns(obj) { @@ -282,17 +282,11 @@ class TemplateBuilder { return node; } - // this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template + // this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). view(view, mountOptions = undefined) { - let root; - try { - root = view.mount(mountOptions); - } catch (err) { - return errorToDOM(err); - } this._templateView.addSubView(view); - return root; + return mountView(view, mountOptions); } // map a value to a view, every time the value changes diff --git a/src/platform/web/ui/general/utils.js b/src/platform/web/ui/general/utils.js new file mode 100644 index 00000000..d74de690 --- /dev/null +++ b/src/platform/web/ui/general/utils.js @@ -0,0 +1,27 @@ +/* +Copyright 2021 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 {errorToDOM} from "./error.js"; + +export function mountView(view, mountArgs = undefined) { + let node; + try { + node = view.mount(mountArgs); + } catch (err) { + node = errorToDOM(err); + } + return node; +} \ No newline at end of file From 4ef5afc01154bc78826159bb1759aa06d778703f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 10:07:52 +0200 Subject: [PATCH 027/213] this is actually not used, so remove it --- src/platform/web/ui/general/SwitchView.js | 94 ----------------------- 1 file changed, 94 deletions(-) delete mode 100644 src/platform/web/ui/general/SwitchView.js diff --git a/src/platform/web/ui/general/SwitchView.js b/src/platform/web/ui/general/SwitchView.js deleted file mode 100644 index ae273265..00000000 --- a/src/platform/web/ui/general/SwitchView.js +++ /dev/null @@ -1,94 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {errorToDOM} from "./error.js"; - -export class SwitchView { - constructor(defaultView) { - this._childView = defaultView; - } - - mount() { - return this._childView.mount(); - } - - unmount() { - return this._childView.unmount(); - } - - root() { - return this._childView.root(); - } - - update() { - return this._childView.update(); - } - - switch(newView) { - const oldRoot = this.root(); - this._childView.unmount(); - this._childView = newView; - let newRoot; - try { - newRoot = this._childView.mount(); - } catch (err) { - newRoot = errorToDOM(err); - } - const parent = oldRoot.parentNode; - if (parent) { - parent.replaceChild(newRoot, oldRoot); - } - } - - get childView() { - return this._childView; - } -} -/* -// SessionLoadView -// should this be the new switch view? -// and the other one be the BasicSwitchView? -new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => { - if (loading) { - return new InlineTemplateView(vm, t => { - return t.div({className: "loading"}, [ - t.span({className: "spinner"}), - t.span(vm => vm.loadingText) - ]); - }); - } else { - return new SessionView(vm.sessionViewModel); - } -}); -*/ -export class BoundSwitchView extends SwitchView { - constructor(value, mapper, viewCreator) { - super(viewCreator(mapper(value), value)); - this._mapper = mapper; - this._viewCreator = viewCreator; - this._mappedValue = mapper(value); - } - - update(value) { - const mappedValue = this._mapper(value); - if (mappedValue !== this._mappedValue) { - this._mappedValue = mappedValue; - this.switch(this._viewCreator(this._mappedValue, value)); - } else { - super.update(value); - } - } -} From 2878208e94ea07b8198872988c65e811f56fffe0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 10:08:07 +0200 Subject: [PATCH 028/213] keep the DOM small, avoid a node for reactions on every message --- .../session/room/timeline/BaseMessageView.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 2f41fa75..9afd7609 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -17,6 +17,7 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; import {tag} from "../../../general/html.js"; +import {mountView} from "../../../general/utils.js"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; @@ -36,10 +37,10 @@ export class BaseMessageView extends TemplateView { unverified: vm.isUnverified, continuation: vm => vm.isContinuation, }}, [ + // dynamically added and removed nodes are handled below this.renderMessageBody(t, vm), // should be after body as it is overlayed on top t.button({className: "Timeline_messageOptions"}, "⋯"), - t.ifView(vm => vm.reactions, vm => new ReactionsView(vm.reactions)), ]); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it @@ -55,6 +56,21 @@ export class BaseMessageView extends TemplateView { li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } }); + // similarly, we could do this with a simple ifView, + // but that adds a comment node to all messages without reactions + let reactionsView = null; + t.mapSideEffect(vm => vm.reactions, reactions => { + if (reactions && !reactionsView) { + reactionsView = new ReactionsView(vm.reactions); + this.addSubView(reactionsView); + li.appendChild(mountView(reactionsView)); + } else if (!reactions && reactionsView) { + li.removeChild(reactionsView.root()); + reactionsView.unmount(); + this.removeSubView(reactionsView); + reactionsView = null; + } + }); return li; } From 8bf160dfc0aae1ec0648686b46541366e8dcb760 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 10:48:59 +0200 Subject: [PATCH 029/213] handle sending relations to events that haven't been sent yet --- src/matrix/room/sending/PendingEvent.js | 18 ++++++++++++++++-- src/matrix/room/sending/SendQueue.js | 9 ++++++++- src/matrix/room/timeline/relations.js | 8 ++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index ef5d086e..a0e5f4f2 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -16,6 +16,7 @@ limitations under the License. import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; import {REDACTION_TYPE} from "../common.js"; +import {getRelationFromContent} from "../timeline/relations.js"; export const SendStatus = createEnum( "Waiting", @@ -49,10 +50,23 @@ export class PendingEvent { get remoteId() { return this._data.remoteId; } get content() { return this._data.content; } get relatedTxnId() { return this._data.relatedTxnId; } - get relatedEventId() { return this._data.relatedEventId; } + get relatedEventId() { + const relation = getRelationFromContent(this.content); + if (relation) { + // may be null when target is not sent yet, is indented + return relation.event_id; + } else { + return this._data.relatedEventId; + } + } setRelatedEventId(eventId) { - this._data.relatedEventId = eventId; + const relation = getRelationFromContent(this.content); + if (relation) { + relation.event_id = eventId; + } else { + this._data.relatedEventId = eventId; + } } get data() { return this._data; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 0e1b116d..914ee1fd 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -19,6 +19,7 @@ import {ConnectionError} from "../../error.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; import {REDACTION_TYPE} from "../common.js"; +import {getRelationFromContent} from "../timeline/relations.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -197,7 +198,13 @@ export class SendQueue { } async enqueueEvent(eventType, content, attachments, log) { - await this._enqueueEvent(eventType, content, attachments, null, null, log); + const relation = getRelationFromContent(content); + let relatedTxnId = null; + if (relation && isTxnId(relation.event_id)) { + relatedTxnId = relation.event_id; + relation.event_id = null; + } + await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log); } async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) { diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index ed9da586..5bf0f490 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -41,7 +41,11 @@ export function getRelatedEventId(event) { return null; } -export function getRelation(event) { - return event.content?.["m.relates_to"]; +export function getRelationFromContent(content) { + return content?.["m.relates_to"]; +} + +export function getRelation(event) { + return getRelationFromContent(event.content); } From b7402ce43cdcadfcc3805b7d978ad9d7e10cb45a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 15:34:44 +0200 Subject: [PATCH 030/213] support local echo for adding a reaction --- .../room/timeline/ReactionsViewModel.js | 81 +++++++++++++++---- .../room/timeline/tiles/BaseMessageTile.js | 8 +- .../room/timeline/PendingAnnotations.js | 69 ++++++++++++++++ .../room/timeline/entries/BaseEventEntry.js | 33 +++++++- .../room/timeline/entries/EventEntry.js | 9 ++- .../ui/session/room/timeline/ReactionsView.js | 2 +- 6 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 src/matrix/room/timeline/PendingAnnotations.js diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 5f3b01a7..62610ed1 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -23,23 +23,47 @@ export class ReactionsViewModel { this._reactions = this._map.sortValues((a, b) => a._compare(b)); } - update(annotations) { - for (const key in annotations) { - if (annotations.hasOwnProperty(key)) { - const annotation = annotations[key]; + update(annotations, pendingAnnotations) { + if (annotations) { + for (const key in annotations) { + if (annotations.hasOwnProperty(key)) { + const annotation = annotations[key]; + const reaction = this._map.get(key); + if (reaction) { + if (reaction._tryUpdate(annotation)) { + this._map.update(key); + } + } else { + this._map.add(key, new ReactionViewModel(key, annotation, 0, this._parentEntry)); + } + } + } + } + if (pendingAnnotations) { + for (const [key, count] of pendingAnnotations.entries()) { const reaction = this._map.get(key); if (reaction) { - if (reaction._tryUpdate(annotation)) { + if (reaction._tryUpdatePending(count)) { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, annotation, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, null, count, this._parentEntry)); } } } for (const existingKey of this._map.keys()) { - if (!annotations.hasOwnProperty(existingKey)) { + const hasPending = pendingAnnotations?.has(existingKey); + const hasRemote = annotations?.hasOwnProperty(existingKey); + if (!hasRemote && !hasPending) { this._map.remove(existingKey); + } else if (!hasRemote) { + if (this._map.get(existingKey)._tryUpdate(null)) { + this._map.update(existingKey); + } + } else if (!hasPending) { + if (this._map.get(existingKey)._tryUpdatePending(0)) { + this._map.update(existingKey); + } } } } @@ -50,43 +74,65 @@ export class ReactionsViewModel { } class ReactionViewModel { - constructor(key, annotation, parentEntry) { + constructor(key, annotation, pendingCount, parentEntry) { this._key = key; this._annotation = annotation; + this._pendingCount = pendingCount; this._parentEntry = parentEntry; } _tryUpdate(annotation) { - if ( + const oneSetAndOtherNot = !!this._annotation !== !!annotation; + const bothSet = this._annotation && annotation; + const areDifferent = bothSet && ( annotation.me !== this._annotation.me || annotation.count !== this._annotation.count || annotation.firstTimestamp !== this._annotation.firstTimestamp - ) { + ); + if (oneSetAndOtherNot || areDifferent) { this._annotation = annotation; return true; } return false; } + _tryUpdatePending(pendingCount) { + if (pendingCount !== this._pendingCount) { + this._pendingCount = pendingCount; + return true; + } + return false; + } + get key() { return this._key; } get count() { - return this._annotation.count; + return (this._annotation?.count || 0) + this._pendingCount; + } + + get isPending() { + return this._pendingCount !== 0; } get haveReacted() { - return this._annotation.me; + return this._annotation?.me || this.isPending; } _compare(other) { - const a = this._annotation; - const b = other._annotation; - if (a.count !== b.count) { - return b.count - a.count; + if (this.count !== other.count) { + return other.count - this.count; } else { - return a.firstTimestamp - b.firstTimestamp; + const a = this._annotation; + const b = other._annotation; + if (a && b) { + return a.firstTimestamp - b.firstTimestamp; + } else if (a) { + return -1; + } else { + return 1; + } } } @@ -97,4 +143,5 @@ class ReactionViewModel { return this._parentEntry.react(this.key); } } +} } \ No newline at end of file diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 719571a9..db126432 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -24,7 +24,7 @@ export class BaseMessageTile extends SimpleTile { this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; this._reactions = null; - if (this._entry.annotations) { + if (this._entry.annotations || this._entry.pendingAnnotations) { this._updateReactions(); } } @@ -135,8 +135,8 @@ export class BaseMessageTile extends SimpleTile { } _updateReactions() { - const {annotations} = this._entry; - if (!annotations) { + const {annotations, pendingAnnotations} = this._entry; + if (!annotations && !pendingAnnotations) { if (this._reactions) { this._reactions = null; } @@ -144,7 +144,7 @@ export class BaseMessageTile extends SimpleTile { if (!this._reactions) { this._reactions = new ReactionsViewModel(this); } - this._reactions.update(annotations); + this._reactions.update(annotations, pendingAnnotations); } } } diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js new file mode 100644 index 00000000..b2b3ea57 --- /dev/null +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -0,0 +1,69 @@ +/* +Copyright 2021 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 {getRelationFromContent} from "./relations.js"; + +class PendingAnnotations { + constructor() { + this.aggregatedAnnotations = new Map(); + this._entries = []; + } + + add(pendingEventEntry) { + const relation = getRelationFromContent(pendingEventEntry.content); + const key = relation.key; + if (!key) { + return; + } + const count = this.aggregatedAnnotations.get(key) || 0; + //const addend = pendingEventEntry.isRedacted ? -1 : 1; + //this.aggregatedAnnotations.set(key, count + addend); + this.aggregatedAnnotations.set(key, count + 1); + this._entries.push(pendingEventEntry); + } + + remove(pendingEventEntry) { + const idx = this._entries.indexOf(pendingEventEntry); + if (idx === -1) { + return; + } + this._entries.splice(idx, 1); + const relation = getRelationFromContent(pendingEventEntry.content); + const key = relation.key; + let count = this.aggregatedAnnotations.get(key); + if (count !== undefined) { + count -= 1; + if (count <= 0) { + this.aggregatedAnnotations.delete(key); + } else { + this.aggregatedAnnotations.set(key, count); + } + } + } + + findForKey(key) { + return this._entries.find(e => { + const relation = getRelationFromContent(e.content); + if (relation.key === key) { + return e; + } + }); + } + + get isEmpty() { + return this._entries.length; + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index a036e8c6..6e3254e8 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,12 +16,14 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; -import {createAnnotation} from "../relations.js"; +import {createAnnotation, getRelationFromContent, ANNOTATION_RELATION_TYPE} from "../relations.js"; +import {PendingAnnotations} from "../PendingAnnotations.js"; export class BaseEventEntry extends BaseEntry { constructor(fragmentIdComparer) { super(fragmentIdComparer); this._pendingRedactions = null; + this._pendingAnnotations = null; } get isRedacting() { @@ -52,6 +54,15 @@ export class BaseEventEntry extends BaseEntry { if (this._pendingRedactions.length === 1) { return "isRedacted"; } + } else { + const relation = getRelationFromContent(entry.content); + if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE) { + if (!this._pendingAnnotations) { + this._pendingAnnotations = new PendingAnnotations(); + } + this._pendingAnnotations.add(entry); + return "pendingAnnotations"; + } } } @@ -69,6 +80,15 @@ export class BaseEventEntry extends BaseEntry { return "isRedacted"; } } + } else { + const relation = getRelationFromContent(entry.content); + if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { + this._pendingAnnotations.remove(entry); + if (this._pendingAnnotations.isEmpty) { + this._pendingAnnotations = null; + } + return "pendingAnnotations"; + } } } @@ -85,4 +105,13 @@ export class BaseEventEntry extends BaseEntry { annotate(key) { return createAnnotation(this.id, key); } -} + + get pendingAnnotations() { + return this._pendingAnnotations?.aggregatedAnnotations; + } + + async getOwnAnnotationId(room, key) { + const pendingEvent = this._pendingAnnotations?.findForKey(key); + return pendingEvent?.id; + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 311cea8c..a106ef7b 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -131,7 +131,12 @@ export class EventEntry extends BaseEventEntry { return this._eventEntry.annotations; } - getOwnAnnotationId(room, key) { - return room.getOwnAnnotationEventId(this.id, key); + async getOwnAnnotationId(room, key) { + const localId = await super.getOwnAnnotationId(room, key); + if (localId) { + return localId; + } else { + return room.getOwnAnnotationEventId(this.id, key); + } } } \ No newline at end of file diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index c1250683..33a34c9f 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -33,7 +33,7 @@ class ReactionView extends TemplateView { render(t, vm) { const haveReacted = vm => vm.haveReacted; return t.button({ - className: {haveReacted}, + className: {haveReacted, isPending: vm => vm.isPending}, }, [vm.key, " ", vm => `${vm.count}`]); } From 919542f8fc699917bdad26e8a196e0affdbac94b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 15:36:01 +0200 Subject: [PATCH 031/213] Don't assume container node exists when loading bundle Only look for the container node when needed --- src/platform/web/ui/common.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/common.js b/src/platform/web/ui/common.js index 9fbafcdf..15a522c7 100644 --- a/src/platform/web/ui/common.js +++ b/src/platform/web/ui/common.js @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -const container = document.querySelector(".hydrogen"); +let container; export function spinner(t, extraClasses = undefined) { - if (container.classList.contains("legacy")) { + if (container === undefined) { + container = document.querySelector(".hydrogen"); + } + if (container?.classList.contains("legacy")) { return t.div({className: "spinner"}, [ t.div(), t.div(), From bb6905bdcdb991687c91b867f0562f6990e3e1c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:05:28 +0200 Subject: [PATCH 032/213] don't assume localEntries exists, as load races with sync.afterSync --- src/matrix/room/timeline/Timeline.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index eab71255..aa331058 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -159,7 +159,9 @@ export class Timeline { // Once the subscription is setup, MappedList will set up the local // relations as needed with _applyAndEmitLocalRelationChange, // so we're not missing anything by bailing out. - if (!this._localEntries.hasSubscriptions) { + // + // _localEntries can also not yet exist + if (!this._localEntries?.hasSubscriptions) { return; } // find any local relations to this new remote event @@ -301,4 +303,4 @@ export function tests() { assert.equal(Array.from(timeline.entries)[0].isRedacting, true); } } -} \ No newline at end of file +} From 7691b28503e4b444b6a6e1dcc5f0037c41a3b105 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:28:08 +0200 Subject: [PATCH 033/213] prevent another race between sync and openTimeline --- src/matrix/room/timeline/Timeline.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index aa331058..ecba1f2a 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -32,7 +32,8 @@ export class Timeline { this._disposables = new Disposables(); this._pendingEvents = pendingEvents; this._clock = clock; - this._remoteEntries = null; + // constructing this early avoid some problem while sync and openTimeline race + this._remoteEntries = new SortedArray((a, b) => a.compare(b)); this._ownMember = null; this._timelineReader = new TimelineReader({ roomId: this._roomId, @@ -96,7 +97,6 @@ export class Timeline { } _setupEntries(timelineEntries) { - this._remoteEntries = new SortedArray((a, b) => a.compare(b)); this._remoteEntries.setManySorted(timelineEntries); if (this._pendingEvents) { this._localEntries = new MappedList(this._pendingEvents, pe => { From 33655ee37e229c650842777319a3c2f46e6a665a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:32:03 +0200 Subject: [PATCH 034/213] forgot to export class --- src/matrix/room/timeline/PendingAnnotations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js index b2b3ea57..6279070b 100644 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -16,7 +16,7 @@ limitations under the License. import {getRelationFromContent} from "./relations.js"; -class PendingAnnotations { +export class PendingAnnotations { constructor() { this.aggregatedAnnotations = new Map(); this._entries = []; From 47e74bd5981759ff67b0d20f7aa0ecac878c20fe Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:32:35 +0200 Subject: [PATCH 035/213] add glow animation for pending reactions --- .../web/ui/css/themes/element/timeline.css | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 03cae338..785dc1cc 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -236,6 +236,19 @@ only loads when the top comes into view*/ border-color: #0DBD8B; } +@keyframes glow-reaction-border { + 0% { border-color: #e9edf1; } + 100% { border-color: #0DBD8B; } +} + +.Timeline_messageReactions button.haveReacted.isPending { + animation-name: glow-reaction-border; + animation-duration: 1s; + animation-direction: alternate; + animation-iteration-count: infinite; + animation-timing-function: linear; +} + .AnnouncementView { margin: 5px 0; padding: 5px 10%; @@ -255,4 +268,4 @@ only loads when the top comes into view*/ .Timeline_messageBody a { word-break: break-all; -} \ No newline at end of file +} From 280de98858a003ae9b5105e1c2848a027e95f2c8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:41:37 +0200 Subject: [PATCH 036/213] fix lint --- src/domain/session/room/timeline/ReactionsViewModel.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 62610ed1..bbd6bdf4 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -144,4 +144,3 @@ class ReactionViewModel { } } } -} \ No newline at end of file From c3848ff56b11f06d57cf885e3b051b2ef756e54a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 07:12:10 +0000 Subject: [PATCH 037/213] Update FAQ.md --- doc/FAQ.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index 4921616e..6105f8fc 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -32,4 +32,8 @@ There are no published builds at this point. You need to checkout the version yo ## I want to embed Hydrogen in my website, how should I do that? -There are no npm modules yet published for Hydrogen. The easiest is probably to setup your website project, do yarn/npm init if you haven't yet, then add the hydrogen repo as a git http dependency, and import the files/classes you want to use from Hydrogen. Feel free to ask which classes you need as the documentation is lacking somewhat still. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet. +There are no npm modules yet published for Hydrogen. The easiest is probably to setup your website project, do yarn/npm init if you haven't yet, then add the hydrogen repo as a git http dependency, and import the files/classes you want to use from Hydrogen. + +For example, for a single room chat, you could create an instance of `Platform`, you create a new `SessionContainer` with it, call `startWithLogin` on it, observe `sessionContainer.loadStatus` to know when initial sync is done, then do `sessionContainer.session.rooms.get('roomid')` and you create a `RoomViewModel` with it and pass that to a `RoomView`. Then you call `document.appendChild(roomView.mount())` and you should see a syncing room. + +Feel free to ask for pointers in #hydrogen:matrix.org as the documentation is still lacking considerably. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet. From 2ebadb36c3d80b53323f539aaab8efd5d69aefff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 13:20:55 +0200 Subject: [PATCH 038/213] WIP --- .../room/timeline/ReactionsViewModel.js | 20 ++++++-- .../room/timeline/tiles/BaseMessageTile.js | 29 ++++++++--- .../session/room/timeline/tiles/SimpleTile.js | 3 +- src/matrix/room/sending/PendingEvent.js | 4 ++ src/matrix/room/sending/SendQueue.js | 13 +++-- .../room/timeline/PendingAnnotations.js | 25 +++++---- src/matrix/room/timeline/Timeline.js | 51 +++++++++++++++++-- .../room/timeline/entries/BaseEventEntry.js | 18 +++++-- 8 files changed, 126 insertions(+), 37 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index bbd6bdf4..55e965a9 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -34,7 +34,7 @@ export class ReactionsViewModel { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, annotation, 0, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentEntry)); } } } @@ -61,7 +61,7 @@ export class ReactionsViewModel { this._map.update(existingKey); } } else if (!hasPending) { - if (this._map.get(existingKey)._tryUpdatePending(0)) { + if (this._map.get(existingKey)._tryUpdatePending(null)) { this._map.update(existingKey); } } @@ -109,11 +109,18 @@ class ReactionViewModel { } get count() { - return (this._annotation?.count || 0) + this._pendingCount; + let count = 0; + if (this._annotation) { + count += this._annotation.count; + } + if (this._pendingCount !== null) { + count += this._pendingCount; + } + return count; } get isPending() { - return this._pendingCount !== 0; + return this._pendingCount !== null; } get haveReacted() { @@ -137,7 +144,10 @@ class ReactionViewModel { } toggleReaction() { - if (this.haveReacted) { + const havePendingReaction = this._pendingCount > 0; + const haveRemoteReaction = this._annotation?.me; + const haveReaction = havePendingReaction || haveRemoteReaction; + if (haveReaction) { return this._parentEntry.redactReaction(this.key); } else { return this._parentEntry.react(this.key); diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index db126432..c8602b83 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -123,15 +123,30 @@ export class BaseMessageTile extends SimpleTile { return true; } - react(key) { - return this._room.sendEvent("m.reaction", this._entry.annotate(key)); + react(key, log = null) { + return this.logger.wrapOrRun(log, "react", log => { + // this assumes the existing reaction is not a remote one + // we would need to do getOwnAnnotation(Id) and see if there are any pending redactions for it + const pee = this._entry.getPendingAnnotationEntry(key); + const redaction = pee?.pendingRedaction; + log.set("has_redaction", !!redaction); + log.set("has_redaction", !!redaction); + if (redaction && !redaction.hasStartedSending) { + log.set("abort_redaction", true); + return redaction.pendingEvent.abort(); + } else { + return this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + } + }); } - async redactReaction(key) { - const id = await this._entry.getOwnAnnotationId(this._room, key); - if (id) { - this._room.sendRedaction(id); - } + async redactReaction(key, log = null) { + return this.logger.wrapOrRun(log, "redactReaction", log => { + const id = await this._entry.getOwnAnnotationId(this._room, key); + if (id) { + this._room.sendRedaction(id, null, log); + } + }); } _updateReactions() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index f4584bcf..8abcc57d 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -54,8 +54,7 @@ export class SimpleTile extends ViewModel { get canAbortSending() { return this._entry.isPending && - this._entry.pendingEvent.status !== SendStatus.Sending && - this._entry.pendingEvent.status !== SendStatus.Sent; + !this._entry.pendingEvent.hasStartedSending; } abortSending() { diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index a0e5f4f2..f1672448 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -116,6 +116,10 @@ export class PendingEvent { get status() { return this._status; } get error() { return this._error; } + get hasStartedSending() { + return this._status !== SendStatus.Sending && this._status !== SendStatus.Sent; + } + get attachmentsTotalBytes() { return this._attachmentsTotalBytes; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 914ee1fd..041b1aef 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -157,8 +157,8 @@ export class SendQueue { } async _removeEvent(pendingEvent) { - const idx = this._pendingEvents.array.indexOf(pendingEvent); - if (idx !== -1) { + let hasEvent = this._pendingEvents.array.indexOf(pendingEvent) !== -1; + if (hasEvent) { const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); try { txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); @@ -166,7 +166,12 @@ export class SendQueue { txn.abort(); } await txn.complete(); - this._pendingEvents.remove(idx); + // lookup index after async txn is complete, + // to make sure we're not racing with anything + const idx = this._pendingEvents.array.indexOf(pendingEvent); + if (idx !== -1) { + this._pendingEvents.remove(idx); + } } pendingEvent.dispose(); } @@ -359,4 +364,4 @@ export function tests() { await poll(() => !queue._isSending); } } -} \ No newline at end of file +} diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js index 6279070b..cb85aa5b 100644 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -22,30 +22,33 @@ export class PendingAnnotations { this._entries = []; } - add(pendingEventEntry) { - const relation = getRelationFromContent(pendingEventEntry.content); + /** adds either a pending annotation entry, or a remote annotation entry with a pending redaction */ + add(annotationEntry) { + const relation = getRelationFromContent(annotationEntry.content); const key = relation.key; if (!key) { return; } const count = this.aggregatedAnnotations.get(key) || 0; - //const addend = pendingEventEntry.isRedacted ? -1 : 1; - //this.aggregatedAnnotations.set(key, count + addend); - this.aggregatedAnnotations.set(key, count + 1); - this._entries.push(pendingEventEntry); + const addend = annotationEntry.isRedacted ? -1 : 1; + console.log("add", count, addend); + this.aggregatedAnnotations.set(key, count + addend); + this._entries.push(annotationEntry); } - remove(pendingEventEntry) { - const idx = this._entries.indexOf(pendingEventEntry); + /** removes either a pending annotation entry, or a remote annotation entry with a pending redaction */ + remove(annotationEntry) { + const idx = this._entries.indexOf(annotationEntry); if (idx === -1) { return; } this._entries.splice(idx, 1); - const relation = getRelationFromContent(pendingEventEntry.content); + const relation = getRelationFromContent(annotationEntry.content); const key = relation.key; let count = this.aggregatedAnnotations.get(key); if (count !== undefined) { - count -= 1; + const addend = annotationEntry.isRedacted ? 1 : -1; + count += addend; if (count <= 0) { this.aggregatedAnnotations.delete(key); } else { @@ -66,4 +69,4 @@ export class PendingAnnotations { get isEmpty() { return this._entries.length; } -} \ No newline at end of file +} diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index ecba1f2a..b567eff8 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,6 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; +import {getRelationFromContent} from "./relations.js"; export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { @@ -101,20 +102,62 @@ export class Timeline { if (this._pendingEvents) { this._localEntries = new MappedList(this._pendingEvents, pe => { const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock}); - this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.addLocalRelation(pee)); + this._onAddPendingEvent(pee); return pee; }, (pee, params) => { // is sending but redacted, who do we detect that here to remove the relation? pee.notifyUpdate(params); - }, pee => { - this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.removeLocalRelation(pee)); - }); + }, pee => this._onRemovePendingEvent(pee)); } else { this._localEntries = new ObservableArray(); } this._allEntries = new ConcatList(this._remoteEntries, this._localEntries); } + _onAddPendingEvent(pee) { + let redactedEntry; + this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => { + const wasRedacted = target.isRedacted; + const params = target.addLocalRelation(pee); + if (!wasRedacted && target.isRedacted) { + redactedEntry = target; + } + return params; + }); + console.log("redactedEntry", redactedEntry); + if (redactedEntry) { + const redactedRelation = getRelationFromContent(redactedEntry.content); + if (redactedRelation?.event_id) { + const found = this._remoteEntries.findAndUpdate( + e => e.id === redactedRelation.event_id, + relationTarget => relationTarget.addLocalRelation(redactedEntry) || false + ); + console.log("found", found); + } + } + } + + _onRemovePendingEvent(pee) { + let unredactedEntry; + this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => { + const wasRedacted = target.isRedacted; + const params = target.removeLocalRelation(pee); + if (wasRedacted && !target.isRedacted) { + unredactedEntry = target; + } + return params; + }); + if (unredactedEntry) { + const redactedRelation = getRelationFromContent(unredactedEntry.content); + if (redactedRelation?.event_id) { + this._remoteEntries.findAndUpdate( + e => e.id === redactedRelation.event_id, + relationTarget => relationTarget.removeLocalRelation(unredactedEntry) || false + ); + } + } + } + _applyAndEmitLocalRelationChange(pe, updater) { const updateOrFalse = e => { const params = updater(e); diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 6e3254e8..b32494e8 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -42,7 +42,7 @@ export class BaseEventEntry extends BaseEntry { } /** - aggregates local relation. + aggregates local relation or local redaction of remote relation. @return [string] returns the name of the field that has changed, if any */ addLocalRelation(entry) { @@ -102,6 +102,13 @@ export class BaseEventEntry extends BaseEntry { } } + get pendingRedaction() { + if (this._pendingRedactions) { + return this._pendingRedactions[0]; + } + return null; + } + annotate(key) { return createAnnotation(this.id, key); } @@ -111,7 +118,10 @@ export class BaseEventEntry extends BaseEntry { } async getOwnAnnotationId(room, key) { - const pendingEvent = this._pendingAnnotations?.findForKey(key); - return pendingEvent?.id; + return this.getPendingAnnotationEntry(key)?.id; } -} \ No newline at end of file + + getPendingAnnotationEntry(key) { + return this._pendingAnnotations?.findForKey(key); + } +} From 206d18f49898e3777b696e17bca3625aac233138 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 16:56:17 +0200 Subject: [PATCH 039/213] WIP2 --- .../room/timeline/ReactionsViewModel.js | 27 +++++++++++++------ .../room/timeline/tiles/BaseMessageTile.js | 26 ++++++++---------- .../session/room/timeline/tiles/SimpleTile.js | 6 ++++- src/matrix/room/BaseRoom.js | 6 +++-- src/matrix/room/Room.js | 4 +-- src/matrix/room/sending/PendingEvent.js | 2 +- src/matrix/room/timeline/Timeline.js | 22 ++++++++++++--- .../room/timeline/entries/BaseEventEntry.js | 6 +---- .../room/timeline/entries/EventEntry.js | 8 +++--- .../web/ui/css/themes/element/timeline.css | 2 +- 10 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 55e965a9..6a813d03 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -79,6 +79,7 @@ class ReactionViewModel { this._annotation = annotation; this._pendingCount = pendingCount; this._parentEntry = parentEntry; + this._isToggling = false; } _tryUpdate(annotation) { @@ -143,14 +144,24 @@ class ReactionViewModel { } } - toggleReaction() { - const havePendingReaction = this._pendingCount > 0; - const haveRemoteReaction = this._annotation?.me; - const haveReaction = havePendingReaction || haveRemoteReaction; - if (haveReaction) { - return this._parentEntry.redactReaction(this.key); - } else { - return this._parentEntry.react(this.key); + async toggleReaction() { + if (this._isToggling) { + console.log("blocking toggleReaction, call ongoing"); + return; + } + this._isToggling = true; + try { + const haveLocalRedaction = this._pendingCount < 0; + const havePendingReaction = this._pendingCount > 0; + const haveRemoteReaction = this._annotation?.me; + const haveReaction = havePendingReaction || (haveRemoteReaction && !haveLocalRedaction); + if (haveReaction) { + await this._parentEntry.redactReaction(this.key); + } else { + await this._parentEntry.react(this.key); + } + } finally { + this._isToggling = false; } } } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index c8602b83..d4bbcbaa 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -124,27 +124,23 @@ export class BaseMessageTile extends SimpleTile { } react(key, log = null) { - return this.logger.wrapOrRun(log, "react", log => { - // this assumes the existing reaction is not a remote one - // we would need to do getOwnAnnotation(Id) and see if there are any pending redactions for it - const pee = this._entry.getPendingAnnotationEntry(key); - const redaction = pee?.pendingRedaction; - log.set("has_redaction", !!redaction); - log.set("has_redaction", !!redaction); - if (redaction && !redaction.hasStartedSending) { + return this.logger.wrapOrRun(log, "react", async log => { + const existingAnnotation = await this._entry.getOwnAnnotationEntry(this._timeline, key); + const redaction = existingAnnotation?.pendingRedaction; + if (redaction && !redaction.pendingEvent.hasStartedSending) { log.set("abort_redaction", true); - return redaction.pendingEvent.abort(); + await redaction.pendingEvent.abort(); } else { - return this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); } }); } - async redactReaction(key, log = null) { - return this.logger.wrapOrRun(log, "redactReaction", log => { - const id = await this._entry.getOwnAnnotationId(this._room, key); - if (id) { - this._room.sendRedaction(id, null, log); + redactReaction(key, log = null) { + return this.logger.wrapOrRun(log, "redactReaction", async log => { + const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); + if (entry) { + await this._room.sendRedaction(entry.id, null, log); } }); } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 8abcc57d..6ec913c0 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -129,8 +129,12 @@ export class SimpleTile extends ViewModel { return this._options.room; } + get _timeline() { + return this._options.timeline; + } + get _powerLevels() { - return this._options.timeline.powerLevels; + return this._timeline.powerLevels; } get _ownMember() { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 2513f67b..42dc6162 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -452,7 +452,7 @@ export class BaseRoom extends EventEmitter { return observable; } - async getOwnAnnotationEventId(targetId, key) { + async getOwnAnnotationEntry(targetId, key) { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineRelations, @@ -461,7 +461,9 @@ export class BaseRoom extends EventEmitter { for (const relation of relations) { const annotation = await txn.timelineEvents.getByEventId(this.id, relation.sourceEventId); if (annotation.event.sender === this._user.id && getRelation(annotation.event).key === key) { - return annotation.event.event_id; + const eventEntry = new EventEntry(annotation, this._fragmentIdComparer); + // add local relations + return eventEntry; } } return null; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 0361e069..62b5c3ff 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -303,7 +303,7 @@ export class Room extends BaseRoom { /** @public */ sendEvent(eventType, content, attachments, log = null) { - this._platform.logger.wrapOrRun(log, "send", log => { + return this._platform.logger.wrapOrRun(log, "send", log => { log.set("id", this.id); return this._sendQueue.enqueueEvent(eventType, content, attachments, log); }); @@ -311,7 +311,7 @@ export class Room extends BaseRoom { /** @public */ sendRedaction(eventIdOrTxnId, reason, log = null) { - this._platform.logger.wrapOrRun(log, "redact", log => { + return this._platform.logger.wrapOrRun(log, "redact", log => { log.set("id", this.id); return this._sendQueue.enqueueRedaction(eventIdOrTxnId, reason, log); }); diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index f1672448..9f54e3c3 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -117,7 +117,7 @@ export class PendingEvent { get error() { return this._error; } get hasStartedSending() { - return this._status !== SendStatus.Sending && this._status !== SendStatus.Sent; + return this._status === SendStatus.Sending || this._status === SendStatus.Sent; } get attachmentsTotalBytes() { diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index b567eff8..806c86e3 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; -import {getRelationFromContent} from "./relations.js"; +import {getRelationFromContent, getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { @@ -124,7 +124,6 @@ export class Timeline { } return params; }); - console.log("redactedEntry", redactedEntry); if (redactedEntry) { const redactedRelation = getRelationFromContent(redactedEntry.content); if (redactedRelation?.event_id) { @@ -132,7 +131,6 @@ export class Timeline { e => e.id === redactedRelation.event_id, relationTarget => relationTarget.addLocalRelation(redactedEntry) || false ); - console.log("found", found); } } } @@ -182,6 +180,24 @@ export class Timeline { } } + + async getOwnAnnotationEntry(targetId, key) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineEvents, + this._storage.storeNames.timelineRelations, + ]); + const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE); + for (const relation of relations) { + const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId); + if (annotation.event.sender === this._ownMember.userId && getRelation(annotation.event).key === key) { + const eventEntry = new EventEntry(annotation, this._fragmentIdComparer); + this._addLocalRelationsToNewRemoteEntries([eventEntry]); + return eventEntry; + } + } + return null; + } + updateOwnMember(member) { this._ownMember = member; } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index b32494e8..09132a21 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -117,11 +117,7 @@ export class BaseEventEntry extends BaseEntry { return this._pendingAnnotations?.aggregatedAnnotations; } - async getOwnAnnotationId(room, key) { - return this.getPendingAnnotationEntry(key)?.id; - } - - getPendingAnnotationEntry(key) { + async getOwnAnnotationEntry(timeline, key) { return this._pendingAnnotations?.findForKey(key); } } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index a106ef7b..2aa9cba0 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -131,12 +131,12 @@ export class EventEntry extends BaseEventEntry { return this._eventEntry.annotations; } - async getOwnAnnotationId(room, key) { - const localId = await super.getOwnAnnotationId(room, key); + async getOwnAnnotationEntry(timeline, key) { + const localId = await super.getOwnAnnotationEntry(timeline, key); if (localId) { return localId; } else { - return room.getOwnAnnotationEventId(this.id, key); + return timeline.getOwnAnnotationEntry(this.id, key); } } -} \ No newline at end of file +} diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 785dc1cc..b2a425ff 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -243,7 +243,7 @@ only loads when the top comes into view*/ .Timeline_messageReactions button.haveReacted.isPending { animation-name: glow-reaction-border; - animation-duration: 1s; + animation-duration: 0.8s; animation-direction: alternate; animation-iteration-count: infinite; animation-timing-function: linear; From 0685fa26227b5be62b758207817639ad313b1401 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 17:18:16 +0200 Subject: [PATCH 040/213] update olm to 3.2.3 --- package.json | 2 +- scripts/post-install.js | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 175c5cd8..58f9b38f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "base64-arraybuffer": "^0.2.0", "bs58": "^4.0.1", "es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush", - "olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "text-encoding": "^0.7.0" } } diff --git a/scripts/post-install.js b/scripts/post-install.js index 828474ef..150807c3 100644 --- a/scripts/post-install.js +++ b/scripts/post-install.js @@ -70,7 +70,7 @@ async function populateLib() { const libDir = path.join(projectDir, "lib/"); await removeDirIfExists(libDir); await fs.mkdir(libDir); - const olmSrcDir = path.dirname(require.resolve("olm")); + const olmSrcDir = path.dirname(require.resolve("@matrix-org/olm")); const olmDstDir = path.join(libDir, "olm/"); await fs.mkdir(olmDstDir); for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) { diff --git a/yarn.lock b/yarn.lock index 0a6bc448..c1e14fe4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -861,6 +861,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": + version "3.2.3" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" + "@rollup/plugin-babel@^5.1.0": version "5.2.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924" @@ -2069,10 +2073,6 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" -"olm@https://packages.matrix.org/npm/olm/olm-3.1.4.tgz": - version "3.1.4" - resolved "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz#0f03128b7d3b2f614d2216409a1dfccca765fdb3" - on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" From e4a1c996156560f706bd339663ac933465cf86cd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 17:35:17 +0200 Subject: [PATCH 041/213] release v0.1.57 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58f9b38f..aeca01bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.56", + "version": "0.1.57", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From c7ba47204254a3b1563f3399470428d3567da009 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 15:49:31 +0530 Subject: [PATCH 042/213] Add view and vm for RoomInformation Signed-off-by: RMidhunSuresh --- .../session/rightpanel/RoomInfoViewModel.js | 25 +++++++++++++++++++ .../web/ui/session/rightpanel/RoomInfoView.js | 13 ++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/domain/session/rightpanel/RoomInfoViewModel.js create mode 100644 src/platform/web/ui/session/rightpanel/RoomInfoView.js diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js new file mode 100644 index 00000000..c132acf8 --- /dev/null +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -0,0 +1,25 @@ +import { ViewModel } from "../../ViewModel.js"; + +export class RoomInfoViewModel extends ViewModel { + constructor(options) { + super(options); + this._room = options.room; + this._roomSummary = this._room._summary._data; + } + + get roomId() { + return this._room.id; + } + + get name() { + return this._roomSummary.name || this._room._heroes._roomName; + } + + get isEncrypted() { + return !!this._roomSummary.encryption; + } + + get memberCount() { + return this._roomSummary.joinCount; + } +} diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js new file mode 100644 index 00000000..226b6c5e --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -0,0 +1,13 @@ +import { TemplateView } from "../../general/TemplateView.js"; +import { text } from "../../general/html.js"; + +export class RoomInfoView extends TemplateView { + render(t, vm) { + return t.div({ className: "RoomInfo" }, [ + t.div({ className: "RoomName" }, [text(vm.name)]), + t.div({ className: "RoomId" }, [text(vm.roomId)]), + t.div({ className: "RoomMemberCount" }, [text(vm.memberCount)]), + t.div({ className: "RoomEncryption" }, [vm.isEncrypted ? "Encrypted" : "Not Encrypted"]) + ]); + } +} From c7fd0fac0757161446dcdc570a19001a08199933 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 15:50:12 +0530 Subject: [PATCH 043/213] Allow details to be child of room Signed-off-by: RMidhunSuresh --- src/domain/navigation/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 3a9b4a07..310ae694 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -37,7 +37,7 @@ function allowsChild(parent, child) { // downside of the approach: both of these will control which tile is selected return type === "room" || type === "empty-grid-tile"; case "room": - return type === "lightbox"; + return type === "lightbox" || type === "details"; default: return false; } From 986271d02a8b56fcac18c3aa071ba1ce1b8def97 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 17:17:24 +0530 Subject: [PATCH 044/213] Add code to toggle RoomInformation panel Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 7a84e67b..eae63acd 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -16,7 +16,8 @@ limitations under the License. */ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; -import {RoomViewModel} from "./room/RoomViewModel.js"; +import { RoomViewModel } from "./room/RoomViewModel.js"; +import { RoomInfoViewModel } from "./rightpanel/RoomInfoViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; @@ -78,6 +79,10 @@ export class SessionViewModel extends ViewModel { this._updateLightbox(eventId); })); this._updateLightbox(lightbox.get()); + + const details = this.navigation.observe("details"); + this.track(details.subscribe(() => this._toggleRoomInformationPanel())); + } get id() { @@ -112,6 +117,10 @@ export class SessionViewModel extends ViewModel { return this._roomViewModelObservable?.get(); } + get roomInfoViewModel() { + return this._roomInfoViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -240,4 +249,18 @@ export class SessionViewModel extends ViewModel { get lightboxViewModel() { return this._lightboxViewModel; } + + _toggleRoomInformationPanel() { + const roomId = this.navigation.path.get("room").value; + const room = this._sessionContainer.session.rooms.get(roomId); + const enable = !!this.navigation.path.get("details"); + if (!room) { + return; + } + this._roomInfoViewModel = enable ? + this.track(new RoomInfoViewModel(this.childOptions({ room }))) : + this.disposeTracked(this._roomInfoViewModel); + this.emitChange(); + } + } From e39572b98ba4b459dad5a12073848a4a860a5c5a Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 17:18:14 +0530 Subject: [PATCH 045/213] Create RoomInformationView on toggle Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/SessionView.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index c8a77a1b..1208e0b3 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -25,6 +25,7 @@ import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; import {SettingsView} from "./settings/SettingsView.js"; +import { RoomInfoView } from "./rightpanel/RoomInfoView.js"; export class SessionView extends TemplateView { render(t, vm) { @@ -53,6 +54,7 @@ export class SessionView extends TemplateView { return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } }), + t.mapView(vm => vm.roomInfoViewModel, roomInfoViewModel => roomInfoViewModel ? new RoomInfoView(roomInfoViewModel) : null), t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) ]); } From 96959a3c4ced1a967a99cad4370c0b4ccae63030 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 17:59:47 +0530 Subject: [PATCH 046/213] Put name of property in emitChange Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index eae63acd..260a98e6 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -260,7 +260,7 @@ export class SessionViewModel extends ViewModel { this._roomInfoViewModel = enable ? this.track(new RoomInfoViewModel(this.childOptions({ room }))) : this.disposeTracked(this._roomInfoViewModel); - this.emitChange(); + this.emitChange("roomInfoViewModel"); } } From 439910f6caa5821cfb9b7cb9c9e0f8030b7687a7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 20:24:39 +0530 Subject: [PATCH 047/213] Handle case when path.get() may be null Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 260a98e6..b32355ef 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -251,7 +251,7 @@ export class SessionViewModel extends ViewModel { } _toggleRoomInformationPanel() { - const roomId = this.navigation.path.get("room").value; + const roomId = this.navigation.path.get("room")?.value; const room = this._sessionContainer.session.rooms.get(roomId); const enable = !!this.navigation.path.get("details"); if (!room) { From 98d8d44695ffe9f15c689a505e11882df3af5c90 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 20:27:17 +0530 Subject: [PATCH 048/213] Allow details to be child of rooms Signed-off-by: RMidhunSuresh --- src/domain/navigation/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 310ae694..2dab6f0c 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -35,7 +35,7 @@ function allowsChild(parent, child) { return type === "room" || type === "rooms" || type === "settings"; case "rooms": // downside of the approach: both of these will control which tile is selected - return type === "room" || type === "empty-grid-tile"; + return type === "room" || type === "empty-grid-tile" || type === "details"; case "room": return type === "lightbox" || type === "details"; default: From 7e38c3ea88fe2a22a0328d6f25cb2bae526169f6 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 23 May 2021 20:27:58 +0530 Subject: [PATCH 049/213] Remove right panel on grid update if needed Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index b32355ef..1194bff4 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -124,6 +124,8 @@ export class SessionViewModel extends ViewModel { _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); + // Close right-panel if needed + this._toggleRoomInformationPanel(); if (roomIds) { if (!this._gridViewModel) { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ From ddaa9b46c07937e92f8f09dc8919138097f54348 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 24 May 2021 16:38:28 +0530 Subject: [PATCH 050/213] Dispose vm preemptively Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 1194bff4..e96ef6f9 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -253,15 +253,16 @@ export class SessionViewModel extends ViewModel { } _toggleRoomInformationPanel() { + this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); const roomId = this.navigation.path.get("room")?.value; const room = this._sessionContainer.session.rooms.get(roomId); - const enable = !!this.navigation.path.get("details"); if (!room) { return; } - this._roomInfoViewModel = enable ? - this.track(new RoomInfoViewModel(this.childOptions({ room }))) : - this.disposeTracked(this._roomInfoViewModel); + const enable = !!this.navigation.path.get("details"); + if (enable) { + this._roomInfoViewModel = this.track(new RoomInfoViewModel(this.childOptions({ room }))); + } this.emitChange("roomInfoViewModel"); } From c3333f5fe8d02753fc0a4ff2e39b2e795f620226 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 24 May 2021 17:01:29 +0530 Subject: [PATCH 051/213] Extract method Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index e96ef6f9..6570cd54 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -241,8 +241,7 @@ export class SessionViewModel extends ViewModel { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); } if (eventId) { - const roomId = this.navigation.path.get("room").value; - const room = this._sessionContainer.session.rooms.get(roomId); + const room = this._roomFromNavigation(); this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room}))); } this.emitChange("lightboxViewModel"); @@ -252,10 +251,15 @@ export class SessionViewModel extends ViewModel { return this._lightboxViewModel; } - _toggleRoomInformationPanel() { - this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); + _roomFromNavigation() { const roomId = this.navigation.path.get("room")?.value; const room = this._sessionContainer.session.rooms.get(roomId); + return room; + } + + _toggleRoomInformationPanel() { + this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); + const room = this._roomFromNavigation(); if (!room) { return; } From 9a605cc6c6688bb45f913c000c7ea62684141607 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 24 May 2021 17:13:51 +0530 Subject: [PATCH 052/213] Remove unnecessary check Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 6570cd54..77c63d6c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -260,9 +260,6 @@ export class SessionViewModel extends ViewModel { _toggleRoomInformationPanel() { this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); const room = this._roomFromNavigation(); - if (!room) { - return; - } const enable = !!this.navigation.path.get("details"); if (enable) { this._roomInfoViewModel = this.track(new RoomInfoViewModel(this.childOptions({ room }))); From 0d11f85ab3b03793036d6f7cd3bbb246380760df Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 24 May 2021 22:19:05 +0530 Subject: [PATCH 053/213] Add CSS to display sidebar to the right Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 7 +++++++ src/platform/web/ui/css/main.css | 1 + src/platform/web/ui/css/right-panel.css | 3 +++ src/platform/web/ui/session/SessionView.js | 3 ++- 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/platform/web/ui/css/right-panel.css diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 9670afad..1eba5270 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -54,6 +54,13 @@ main { min-width: 0; } +.SideBarActive{ + grid-template: + "status status status" auto + "left middle right" 1fr / + 300px 1fr 300px; +} + /* resize and reposition session view to account for mobile Safari which shifts the layout viewport up without resizing it when the keyboard shows */ .hydrogen.ios .SessionView { diff --git a/src/platform/web/ui/css/main.css b/src/platform/web/ui/css/main.css index aa22839e..82b849c8 100644 --- a/src/platform/web/ui/css/main.css +++ b/src/platform/web/ui/css/main.css @@ -18,6 +18,7 @@ limitations under the License. @import url('layout.css'); @import url('login.css'); @import url('left-panel.css'); +@import url('right-panel.css'); @import url('room.css'); @import url('timeline.css'); @import url('avatar.css'); diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css new file mode 100644 index 00000000..4334ffb1 --- /dev/null +++ b/src/platform/web/ui/css/right-panel.css @@ -0,0 +1,3 @@ +.RoomInfo{ + grid-area: right; +} diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 1208e0b3..2c2a3cd1 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -32,7 +32,8 @@ export class SessionView extends TemplateView { return t.div({ className: { "SessionView": true, - "middle-shown": vm => !!vm.activeMiddleViewModel + "middle-shown": vm => !!vm.activeMiddleViewModel, + "SideBarActive": vm => !!vm.roomInfoViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), From 191eb09a76f5c6a6f0b725a0dd8ebf7a8d90b961 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 24 May 2021 22:50:17 +0530 Subject: [PATCH 054/213] Fallback to canonical alias if no names found Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index c132acf8..951f10c0 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -12,7 +12,7 @@ export class RoomInfoViewModel extends ViewModel { } get name() { - return this._roomSummary.name || this._room._heroes._roomName; + return this._roomSummary.name || this._room._heroes?._roomName || this._roomSummary.canonicalAlias; } get isEncrypted() { From 0ea2843454c281a7b6a041de8db33ffecb65fe63 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 25 May 2021 12:05:12 +0530 Subject: [PATCH 055/213] Add background for sidebar Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 4334ffb1..cc6432e1 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -1,3 +1,4 @@ .RoomInfo{ grid-area: right; + background : rgba(245, 245, 245, 0.90); } From 6f1b77b6fa7074cd78c63d4e0ea8955dd0271edb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 25 May 2021 12:06:09 +0530 Subject: [PATCH 056/213] Add room avatar to RoomInfoView Signed-off-by: RMidhunSuresh --- .../session/rightpanel/RoomInfoViewModel.js | 17 +++++++++++++++++ .../web/ui/session/rightpanel/RoomInfoView.js | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index 951f10c0..a2b7eaf4 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -1,4 +1,5 @@ import { ViewModel } from "../../ViewModel.js"; +import { avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl } from "../../avatar.js"; export class RoomInfoViewModel extends ViewModel { constructor(options) { @@ -22,4 +23,20 @@ export class RoomInfoViewModel extends ViewModel { get memberCount() { return this._roomSummary.joinCount; } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this.roomId) + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._room.avatarUrl, size, this.platform, this._room.mediaRepository); + } + + get avatarTitle() { + return this.name; + } } diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 226b6c5e..fc09d5c6 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -1,9 +1,11 @@ import { TemplateView } from "../../general/TemplateView.js"; import { text } from "../../general/html.js"; +import { AvatarView } from "../../avatar.js"; export class RoomInfoView extends TemplateView { render(t, vm) { return t.div({ className: "RoomInfo" }, [ + t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 50))]), t.div({ className: "RoomName" }, [text(vm.name)]), t.div({ className: "RoomId" }, [text(vm.roomId)]), t.div({ className: "RoomMemberCount" }, [text(vm.memberCount)]), From aece82dff640e86082ea369d74984558bf2e2157 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 25 May 2021 12:18:30 +0530 Subject: [PATCH 057/213] Make room avatar larger in RoomInfoView Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index fc09d5c6..692176fa 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -5,7 +5,7 @@ import { AvatarView } from "../../avatar.js"; export class RoomInfoView extends TemplateView { render(t, vm) { return t.div({ className: "RoomInfo" }, [ - t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 50))]), + t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 128))]), t.div({ className: "RoomName" }, [text(vm.name)]), t.div({ className: "RoomId" }, [text(vm.roomId)]), t.div({ className: "RoomMemberCount" }, [text(vm.memberCount)]), From d502a7f911adb9d378deb7d2576b5a9784576c05 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 25 May 2021 16:09:47 +0530 Subject: [PATCH 058/213] Make display name bold Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 692176fa..f591e0c1 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -6,7 +6,7 @@ export class RoomInfoView extends TemplateView { render(t, vm) { return t.div({ className: "RoomInfo" }, [ t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 128))]), - t.div({ className: "RoomName" }, [text(vm.name)]), + t.div({ className: "RoomName" }, [t.h2(vm.name)]), t.div({ className: "RoomId" }, [text(vm.roomId)]), t.div({ className: "RoomMemberCount" }, [text(vm.memberCount)]), t.div({ className: "RoomEncryption" }, [vm.isEncrypted ? "Encrypted" : "Not Encrypted"]) From a23e2c361a2a41b615baf4c520265a3a6f93e675 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 25 May 2021 16:10:05 +0530 Subject: [PATCH 059/213] Better styling Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index cc6432e1..23f140c3 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -1,4 +1,8 @@ .RoomInfo{ grid-area: right; background : rgba(245, 245, 245, 0.90); + display: flex; + flex-direction: column; + align-items: center; + padding:10px; } From 0a4f8aff7961a5ca2b1ffa70e555d7c0d51b7026 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:36:29 +0530 Subject: [PATCH 060/213] Create method to add sidebar element Signed-off-by: RMidhunSuresh --- .../web/ui/session/rightpanel/RoomInfoView.js | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index f591e0c1..ef70762d 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -1,15 +1,29 @@ import { TemplateView } from "../../general/TemplateView.js"; -import { text } from "../../general/html.js"; +import { text, classNames, tag } from "../../general/html.js"; import { AvatarView } from "../../avatar.js"; export class RoomInfoView extends TemplateView { + render(t, vm) { + const encryptionString = vm.isEncrypted ? "On" : "Off"; return t.div({ className: "RoomInfo" }, [ t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 128))]), t.div({ className: "RoomName" }, [t.h2(vm.name)]), + t.div({ className: "RoomId" }, [text(vm.roomId)]), - t.div({ className: "RoomMemberCount" }, [text(vm.memberCount)]), - t.div({ className: "RoomEncryption" }, [vm.isEncrypted ? "Encrypted" : "Not Encrypted"]) + + this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), + + this._createSideBarRow("Encryption", encryptionString, { EncryptionStatus: true }) + ]); + } + + _createSideBarRow(label, value, labelClass, valueClass) { + const labelClassString = classNames({ SidebarLabel: true, ...labelClass }); + const valueClassString = classNames({ SidebarValue: true, ...valueClass }); + return tag.div({ className: "SidebarRow" }, [ + tag.div({ className: labelClassString }, [text(label)]), + tag.div({ className: valueClassString }, [text(value)]) ]); } } From 653fcbbb1f883a429a0605bff06953dfc225b263 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:38:26 +0530 Subject: [PATCH 061/213] Add method to create avatar section Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index ef70762d..79753295 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -7,7 +7,10 @@ export class RoomInfoView extends TemplateView { render(t, vm) { const encryptionString = vm.isEncrypted ? "On" : "Off"; return t.div({ className: "RoomInfo" }, [ - t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 128))]), + + t.div({ className: "RoomAvatar" }, + [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), + t.div({ className: "RoomName" }, [t.h2(vm.name)]), t.div({ className: "RoomId" }, [text(vm.roomId)]), @@ -26,4 +29,9 @@ export class RoomInfoView extends TemplateView { tag.div({ className: valueClassString }, [text(value)]) ]); } + + _createEncryptionIcon(isEncrypted) { + return tag.div({ className: "RoomEncryption" }, + [tag.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]) + } } From ee29d7f799dc353883145ed72d8283b427bae5b5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:39:19 +0530 Subject: [PATCH 062/213] Bring in icon assets Signed-off-by: RMidhunSuresh --- .../web/ui/css/themes/element/icons/e2ee-disabled.svg | 5 +++++ .../web/ui/css/themes/element/icons/e2ee-normal.svg | 3 +++ .../web/ui/css/themes/element/icons/encryption-status.svg | 3 +++ .../web/ui/css/themes/element/icons/room-members.svg | 7 +++++++ 4 files changed, 18 insertions(+) create mode 100644 src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg create mode 100644 src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg create mode 100644 src/platform/web/ui/css/themes/element/icons/encryption-status.svg create mode 100644 src/platform/web/ui/css/themes/element/icons/room-members.svg diff --git a/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg b/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg new file mode 100644 index 00000000..26e669fc --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg b/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg new file mode 100644 index 00000000..9d981ee7 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/encryption-status.svg b/src/platform/web/ui/css/themes/element/icons/encryption-status.svg new file mode 100644 index 00000000..8c81d4cd --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/encryption-status.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/room-members.svg b/src/platform/web/ui/css/themes/element/icons/room-members.svg new file mode 100644 index 00000000..bc03be13 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/room-members.svg @@ -0,0 +1,7 @@ + + + + + + + From f42553f8cb3d1195ddcae1c6f3918a8950e85f72 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:40:25 +0530 Subject: [PATCH 063/213] Add avatar size for design Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/avatar.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 6e68236f..b2405896 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -37,6 +37,7 @@ limitations under the License. } /* work around postcss-css-variables limitations and repeat variable usage */ + .hydrogen .avatar.size-128 { --avatar-size: 128px; width: var(--avatar-size); @@ -53,6 +54,14 @@ limitations under the License. font-size: calc(var(--avatar-size) * 0.6); } +.hydrogen .avatar.size-52 { + --avatar-size: 52px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + .hydrogen .avatar.size-30 { --avatar-size: 30px; width: var(--avatar-size); From c96abc889205cda589f1d4afa7ffcc626ebb63f7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:41:23 +0530 Subject: [PATCH 064/213] Add css to place items appropriately Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 23f140c3..76cf3091 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -1,8 +1,26 @@ -.RoomInfo{ +.RoomInfo { grid-area: right; - background : rgba(245, 245, 245, 0.90); - display: flex; flex-direction: column; - align-items: center; - padding:10px; +} + +.RoomAvatar { + display: flex; +} + +.RoomName h2 { + text-align: center; +} + +.SidebarRow { + justify-content: space-between; +} + +.SidebarLabel, .SidebarRow, +.RoomInfo, .RoomEncryption { + display: flex; + align-items: center; +} + +.RoomEncryption { + justify-content: center; } From b0535b5d7d501107cddd2625e1624d9f8e4f1673 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:43:01 +0530 Subject: [PATCH 065/213] Add styling for RoomInfoView Signed-off-by: RMidhunSuresh --- .../web/ui/css/themes/element/theme.css | 210 ++++++++++++------ 1 file changed, 148 insertions(+), 62 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index b0bf7854..eea30fc0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -22,7 +22,6 @@ limitations under the License. font-size: 10px; } - .hydrogen { font-family: 'Inter', sans-serif, 'emoji'; background-color: white; @@ -48,14 +47,37 @@ limitations under the License. color: white; } -.hydrogen .avatar.usercolor1 { background-color: var(--usercolor1); } -.hydrogen .avatar.usercolor2 { background-color: var(--usercolor2); } -.hydrogen .avatar.usercolor3 { background-color: var(--usercolor3); } -.hydrogen .avatar.usercolor4 { background-color: var(--usercolor4); } -.hydrogen .avatar.usercolor5 { background-color: var(--usercolor5); } -.hydrogen .avatar.usercolor6 { background-color: var(--usercolor6); } -.hydrogen .avatar.usercolor7 { background-color: var(--usercolor7); } -.hydrogen .avatar.usercolor8 { background-color: var(--usercolor8); } +.hydrogen .avatar.usercolor1 { + background-color: var(--usercolor1); +} + +.hydrogen .avatar.usercolor2 { + background-color: var(--usercolor2); +} + +.hydrogen .avatar.usercolor3 { + background-color: var(--usercolor3); +} + +.hydrogen .avatar.usercolor4 { + background-color: var(--usercolor4); +} + +.hydrogen .avatar.usercolor5 { + background-color: var(--usercolor5); +} + +.hydrogen .avatar.usercolor6 { + background-color: var(--usercolor6); +} + +.hydrogen .avatar.usercolor7 { + background-color: var(--usercolor7); +} + +.hydrogen .avatar.usercolor8 { + background-color: var(--usercolor8); +} .logo { height: 48px; @@ -66,13 +88,16 @@ limitations under the License. } /** buttons */ + .button-row { display: flex; } -.button-row > * { + +.button-row>* { margin-right: 10px; } -.button-row > *:last-child { + +.button-row>*:last-child { margin-right: 0px; } @@ -107,7 +132,6 @@ a.button-action { display: block; } - .button-action.secondary { color: #03B381; } @@ -172,7 +196,8 @@ a.button-action { border-radius: 16px; height: 32px; align-items: center; - padding-left: 30px; /* 8 + 14 (icon) + 8*/ + padding-left: 30px; + /* 8 + 14 (icon) + 8*/ box-sizing: border-box; } @@ -180,6 +205,7 @@ a.button-action { border: 1px #e1e3e6 solid; background-color: white; } + .FilterField:focus-within button { border-color: white; } @@ -200,13 +226,16 @@ a.button-action { } .FilterField button { - width: 30px; /* 32 - 1 (top) - 1 (bottom) */ - height: 30px; /* 32 - 1 (top) - 1 (bottom) */ + width: 30px; + /* 32 - 1 (top) - 1 (bottom) */ + height: 30px; + /* 32 - 1 (top) - 1 (bottom) */ background-position: center; background-color: #e1e3e6; background-repeat: no-repeat; background-image: url('icons/clear.svg'); - border: 7px solid transparent; /* 8 - 1 */ + border: 7px solid transparent; + /* 8 - 1 */ border-radius: 100%; box-sizing: border-box; } @@ -242,11 +271,11 @@ a.button-action { padding: 12px 8px 0 8px; } -.LeftPanel > :not(:first-child) { +.LeftPanel> :not(:first-child) { margin-top: 12px; } -.LeftPanel .utilities > :not(:first-child) { +.LeftPanel .utilities> :not(:first-child) { margin-left: 8px; } @@ -266,14 +295,14 @@ a.button-action { margin-right: -8px; } -.RoomList > li { +.RoomList>li { margin: 0; padding: 4px 8px 4px 0; /* vertical align */ align-items: center; } -.RoomList > li > a { +.RoomList>li>a { text-decoration: none; /* vertical align */ align-items: center; @@ -290,7 +319,7 @@ a.button-action { border-radius: 5px; } -.RoomList li > a > * { +.RoomList li>a>* { margin-right: 8px; } @@ -332,13 +361,12 @@ a { align-items: center; } - .SessionStatusView button.link { color: currentcolor; text-align: left; } -.SessionStatusView > .end { +.SessionStatusView>.end { flex: 1; display: flex; justify-content: flex-end; @@ -364,7 +392,7 @@ a { } .SessionPickerView li { - font-size: 1.2em; + font-size: 1.2em; } .SessionPickerView .session-info { @@ -383,10 +411,11 @@ a { display: flex; } -.SessionPickerView .session-actions > * { +.SessionPickerView .session-actions>* { margin-right: 10px; } -.SessionPickerView .session-actions > *:last-child { + +.SessionPickerView .session-actions>*:last-child { margin-right: 0px; } @@ -400,16 +429,16 @@ a { color: #FF4B55; } -.RoomGridView > div.container { +.RoomGridView>div.container { border-right: 1px solid rgba(245, 245, 245, 0.90); border-bottom: 1px solid rgba(245, 245, 245, 0.90); } -.RoomGridView > .focused > .room-placeholder .unfocused { +.RoomGridView>.focused>.room-placeholder .unfocused { display: none; } -.RoomGridView > :not(.focused) > .room-placeholder .focused { +.RoomGridView> :not(.focused)>.room-placeholder .focused { display: none; } @@ -417,15 +446,15 @@ a { color: #8D99A5; } - -.RoomGridView > div.focus-ring { +.RoomGridView>div.focus-ring { border: 2px solid rgba(134, 193, 165, 1); border-radius: 12px; } .middle-header { box-sizing: border-box; - flex: 0 0 56px; /* 12 + 32 + 12 to align with filter field + margin */ + flex: 0 0 56px; + /* 12 + 32 + 12 to align with filter field + margin */ background: white; padding: 0 16px; border-bottom: 1px solid rgba(245, 245, 245, 0.90); @@ -436,7 +465,7 @@ a { font-weight: 600; } -.middle-header > :not(:last-child) { +.middle-header> :not(:last-child) { /* use margin-right because the first item, .close-middle might be hidden and then we don't want a margin-left on the second item*/ @@ -465,11 +494,11 @@ a { padding: 8px 16px; } -.MessageComposer > :not(:first-child) { +.MessageComposer> :not(:first-child) { margin-left: 12px; } -.MessageComposer > input { +.MessageComposer>input { padding: 0 16px; border: none; border-radius: 24px; @@ -479,7 +508,7 @@ a { font-family: "Inter", sans-serif; } -.MessageComposer > button.send { +.MessageComposer>button.send { width: 32px; height: 32px; display: block; @@ -487,14 +516,13 @@ a { border: none; text-indent: 200%; overflow: hidden; - background-color: #03B381; background-image: url('icons/send.svg'); background-repeat: no-repeat; background-position: center; } -.MessageComposer > button.sendFile { +.MessageComposer>button.sendFile { width: 32px; height: 32px; display: block; @@ -508,7 +536,7 @@ a { background-position: center; } -.MessageComposer > button.send:disabled { +.MessageComposer>button.send:disabled { background-color: #E3E8F0; } @@ -521,7 +549,7 @@ a { } .Settings p { - max-width: 700px; + max-width: 700px; } .Settings .row .label { @@ -543,14 +571,14 @@ a { } .Settings .row .content button { - display: inline-block; - margin: 0 8px; + display: inline-block; + margin: 0 8px; } .Settings .row .content input[type=range] { - width: 100%; - max-width: 300px; - min-width: 160px; + width: 100%; + max-width: 300px; + min-width: 160px; } .Settings .row { @@ -585,25 +613,17 @@ button.link { } .lightbox { - background-color: rgba(0,0,0,0.75); + background-color: rgba(0, 0, 0, 0.75); display: grid; - grid-template: - "content close" auto - "content details" 1fr / - 1fr auto; + grid-template: "content close" auto "content details" 1fr / 1fr auto; color: white; padding: 4px; } @media (max-aspect-ratio: 1/1) { .lightbox { - grid-template: - "close" auto - "content" 1fr - "details" auto / - 1fr; + grid-template: "close" auto "content" 1fr "details" auto / 1fr; } - .lightbox .details { width: 100% !important; } @@ -627,7 +647,7 @@ button.link { display: flex; } -.lightbox .loading > :not(:first-child) { +.lightbox .loading> :not(:first-child) { margin-left: 8px; } @@ -653,7 +673,7 @@ button.link { .menu { border-radius: 8px; - box-shadow: 2px 2px 10px rgba(0,0,0,0.5); + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); padding: 4px; background-color: white; list-style: none; @@ -693,10 +713,7 @@ button.link { .InviteView_roomProfile { display: grid; gap: 4px; - grid-template: - "avatar name" auto - "avatar description" 1fr / - 72px 1fr; + grid-template: "avatar name" auto "avatar description" 1fr / 72px 1fr; align-self: center; margin-bottom: 24px; } @@ -769,3 +786,72 @@ button.link { max-width: 200px; width: 100%; } + +/* Right Panel */ + +.RoomInfo { + background: rgba(245, 245, 245, 0.90); + padding: 10px; +} + +.RoomAvatar { + margin-top: 44px; +} + +.RoomId { + color: #737D8C; + font-size: 12px; + margin-bottom: 36px; +} + +.RoomName h2 { + margin-bottom: 4px; +} + +.SidebarRow { + width: 90%; + margin-bottom: 20px; + font-weight: 500; + font-size: 15px; +} + +.SidebarLabel::before { + padding-right: 16px; + height: 20px; + width: 20px; +} + +.SidebarValue { + color: #737D8C; +} + +.MemberCount::before { + content: url("./icons/room-members.svg"); +} + +.EncryptionStatus::before { + content: url("./icons/encryption-status.svg"); +} + +/* Encryption icon next to avatar */ +.RoomEncryption { + width: 52px; + height: 52px; + border-radius: 100%; + background: #737D8C; + border: 3px solid #F2F5F8; + margin-left: -16px; +} + +.encrypted, .unencrypted { + height: 24px; + width: 24px; +} + +.encrypted { + content: url("./icons/e2ee-normal.svg"); +} + +.unencrypted { + content: url("./icons/e2ee-disabled.svg"); +} From 91f083a2458bf0779bca38620fe42a50ab287c15 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:58:31 +0530 Subject: [PATCH 066/213] Add close button to RoomInfoView Signed-off-by: RMidhunSuresh --- .../web/ui/session/rightpanel/RoomInfoView.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 79753295..57190e7c 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -6,17 +6,14 @@ export class RoomInfoView extends TemplateView { render(t, vm) { const encryptionString = vm.isEncrypted ? "On" : "Off"; - return t.div({ className: "RoomInfo" }, [ + return t.div({ className: "RoomInfo" }, [ + this._createButton(), t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), - t.div({ className: "RoomName" }, [t.h2(vm.name)]), - t.div({ className: "RoomId" }, [text(vm.roomId)]), - this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), - this._createSideBarRow("Encryption", encryptionString, { EncryptionStatus: true }) ]); } @@ -34,4 +31,8 @@ export class RoomInfoView extends TemplateView { return tag.div({ className: "RoomEncryption" }, [tag.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]) } + + _createButton() { + return tag.div({ className: "buttons" }, [tag.div({ className: "close button-utility" })]); + } } From c65e26ec4c65d97aa3246ac12fd788b1a645ac7d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:59:04 +0530 Subject: [PATCH 067/213] Position the close button to right end Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 76cf3091..ab2879fc 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -15,12 +15,17 @@ justify-content: space-between; } -.SidebarLabel, .SidebarRow, -.RoomInfo, .RoomEncryption { +.SidebarLabel, .SidebarRow, .RoomInfo, .RoomEncryption { display: flex; align-items: center; -} +} .RoomEncryption { justify-content: center; } + +.buttons { + display: flex; + justify-content: right; + width: 100%; +} From 20a250dfc0d5c0ac893d2dbbd137d11b1041e891 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 15:59:28 +0530 Subject: [PATCH 068/213] Style close button Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index eea30fc0..954af9a2 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -834,8 +834,9 @@ button.link { } /* Encryption icon next to avatar */ + .RoomEncryption { - width: 52px; + width: 52px; height: 52px; border-radius: 100%; background: #737D8C; @@ -855,3 +856,12 @@ button.link { .unencrypted { content: url("./icons/e2ee-disabled.svg"); } + +.RoomInfo .button-utility { + width: 24px; + height: 24px; +} + +.RoomInfo .close { + background-image: url("./icons/clear.svg"); +} From 37367cde652f50ed4bb04d72ee9e8a0e2f74a6dd Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 16:11:49 +0530 Subject: [PATCH 069/213] Make close button close the view Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 4 ++++ src/platform/web/ui/session/rightpanel/RoomInfoView.js | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index a2b7eaf4..c74ae826 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -39,4 +39,8 @@ export class RoomInfoViewModel extends ViewModel { get avatarTitle() { return this.name; } + + get closeLink() { + return this.urlCreator.urlUntilSegment("room"); + } } diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 57190e7c..04520602 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -8,7 +8,7 @@ export class RoomInfoView extends TemplateView { const encryptionString = vm.isEncrypted ? "On" : "Off"; return t.div({ className: "RoomInfo" }, [ - this._createButton(), + this._createButton(vm), t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), t.div({ className: "RoomName" }, [t.h2(vm.name)]), @@ -32,7 +32,8 @@ export class RoomInfoView extends TemplateView { [tag.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]) } - _createButton() { - return tag.div({ className: "buttons" }, [tag.div({ className: "close button-utility" })]); + _createButton(vm) { + return tag.div({ className: "buttons" }, + [tag.a({ className: "close button-utility", href: vm.closeLink })]); } } From ecd5505af9c5cfd24dfbfe8f71dfd51e62e30c3d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 16:40:21 +0530 Subject: [PATCH 070/213] Bring in info icon Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/icons/info.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/platform/web/ui/css/themes/element/icons/info.svg diff --git a/src/platform/web/ui/css/themes/element/icons/info.svg b/src/platform/web/ui/css/themes/element/icons/info.svg new file mode 100644 index 00000000..d55e9356 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/info.svg @@ -0,0 +1,3 @@ + + + From 4edc58ebcf2c65a1c93bab361f866813a0912bcf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 16:40:50 +0530 Subject: [PATCH 071/213] Add button in Room header to open details Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index d2d46c32..20ba8034 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -104,6 +104,10 @@ export class RoomViewModel extends ViewModel { get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } + get roomDetailsLink() { + return this.urlCreator.urlForSegment("details"); + } + get error() { if (this._timelineError) { return `Something went wrong loading the timeline: ${this._timelineError.message}`; @@ -383,4 +387,4 @@ class ArchivedViewModel extends ViewModel { get kind() { return "archived"; } -} \ No newline at end of file +} From b5480b018b0c07eaa7e38f66d56bf68fd995e398 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 16:41:10 +0530 Subject: [PATCH 072/213] Style open button Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 954af9a2..8264963f 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -485,6 +485,10 @@ a { background-image: url("./icons/vertical-ellipsis.svg"); } +.RoomHeader .room-info { + background-image: url("./icons/info.svg"); +} + .RoomView_error { color: red; } From 9ac415fa4f288da11aa1da5418910d33332d90de Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 17:59:50 +0530 Subject: [PATCH 073/213] Show panel when first visit contains /details Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 77c63d6c..a570e14f 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -82,6 +82,7 @@ export class SessionViewModel extends ViewModel { const details = this.navigation.observe("details"); this.track(details.subscribe(() => this._toggleRoomInformationPanel())); + this._toggleRoomInformationPanel(); } From eab6ca3baf808a03552b7cca600228ef838766e9 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 22:23:09 +0530 Subject: [PATCH 074/213] Make side-bar responsive Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 1eba5270..71274a31 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -72,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { - .SessionView:not(.middle-shown) { + .SessionView:not(.middle-shown, .SideBarActive) { grid-template: "status" auto "left" 1fr / @@ -86,8 +86,17 @@ the layout viewport up without resizing it when the keyboard shows */ 1fr; } + .SessionView.SideBarActive{ + grid-template: + "status" auto + "right" 1fr / + 1fr; + + } + .SessionView:not(.middle-shown) .room-placeholder { display: none; } .SessionView.middle-shown .LeftPanel { display: none; } + .SessionView.SideBarActive .middle { display: none; } /* show back button */ .middle .close-middle { display: block !important; } From d782c9e0bb92af68398283775600bf85124bebce Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 26 May 2021 22:50:27 +0530 Subject: [PATCH 075/213] Justify content to flex-end instead Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index ab2879fc..19b45596 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -26,6 +26,6 @@ .buttons { display: flex; - justify-content: right; + justify-content: flex-end; width: 100%; } From 2dcec6343d0cc6d6de8496c623b0ed25a2acb82e Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 27 May 2021 23:57:28 +0530 Subject: [PATCH 076/213] Info icon should open/close view alternatively Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 20ba8034..00008039 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -287,6 +287,13 @@ export class RoomViewModel extends ViewModel { console.error(err.stack); } } + + toggleDetailsPanel() { + const isPanelOpen = !!this.navigation.path.get("details"); + const link = isPanelOpen ? this.urlCreator.urlUntilSegment("room"): + this.urlCreator.urlForSegment("details"); + window.location.href = link; + } get composerViewModel() { return this._composerVM; From 02d79b52a4ad77738606da51f0d85bc6e1cc4690 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 28 May 2021 12:58:46 +0530 Subject: [PATCH 077/213] Prefer canonical alias over room_id if available Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index c74ae826..dd74e110 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -9,7 +9,7 @@ export class RoomInfoViewModel extends ViewModel { } get roomId() { - return this._room.id; + return this._roomSummary.canonicalAlias || this._room.id; } get name() { From 426d0779ee68677679783b3e4f3aef9a5628c3d9 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 31 May 2021 22:14:43 +0530 Subject: [PATCH 078/213] Keep RoomInfoView open across room/grid changes Signed-off-by: RMidhunSuresh --- src/domain/navigation/index.js | 3 +++ src/domain/session/RoomGridViewModel.js | 16 ++++++++++------ src/domain/session/SessionViewModel.js | 3 +-- .../session/leftpanel/LeftPanelViewModel.js | 3 +++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 2dab6f0c..5e1a1b62 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -113,6 +113,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); } segments.push(new Segment("room", roomId)); + if (currentNavPath.get("details")?.value) { + segments.push(new Segment("details")); + } } else if (type === "last-session") { let sessionSegment = currentNavPath.get("session"); if (typeof sessionSegment?.value !== "string" && defaultSessionId) { diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index aee80b6a..8fce26ad 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import { ViewModel } from "../ViewModel.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -84,7 +84,11 @@ export class RoomGridViewModel extends ViewModel { } const vmo = this._viewModelsObservables[index]; if (vmo) { + const detailsShown = !!this.navigation.path.get("details"); this.navigation.push("room", vmo.id); + if (detailsShown) { + this.navigation.push("details", true); + } } else { this.navigation.push("empty-grid-tile", index); } @@ -146,7 +150,7 @@ export class RoomGridViewModel extends ViewModel { this.emitChange(); viewModel?.focus(); } - + /** called from SessionViewModel */ releaseRoomViewModel(roomId) { const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === roomId); @@ -177,10 +181,10 @@ export class RoomGridViewModel extends ViewModel { } } -import {createNavigation} from "../navigation/index.js"; -import {ObservableValue} from "../../observable/ObservableValue.js"; +import { createNavigation } from "../navigation/index.js"; +import { ObservableValue } from "../../observable/ObservableValue.js"; -export function tests() { +export function tests() { class RoomVMMock { constructor(id) { this.id = id; @@ -196,7 +200,7 @@ export function tests() { } class RoomViewModelObservableMock extends ObservableValue { - async initialize() {} + async initialize() { } dispose() { this.get()?.dispose(); } get id() { return this.get()?.id; } } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index a570e14f..1ad5d50e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -63,6 +63,7 @@ export class SessionViewModel extends ViewModel { if (!this._gridViewModel) { this._updateRoom(roomId); } + this._toggleRoomInformationPanel(); })); if (!this._gridViewModel) { this._updateRoom(currentRoomId.get()); @@ -125,8 +126,6 @@ export class SessionViewModel extends ViewModel { _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); - // Close right-panel if needed - this._toggleRoomInformationPanel(); if (roomIds) { if (!this._gridViewModel) { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 6503c124..96becf2a 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -93,11 +93,13 @@ export class LeftPanelViewModel extends ViewModel { } toggleGrid() { + const details = this.navigation.path.get("details"); if (this.gridEnabled) { let path = this.navigation.path.until("session"); const room = this.navigation.path.get("room"); if (room) { path = path.with(room); + path = path.with(details); } this.navigation.applyPath(path); } else { @@ -106,6 +108,7 @@ export class LeftPanelViewModel extends ViewModel { if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); + path = path.with(details); } else { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); From 1e96b58f85b39cb2a4cc2c4b75d86eb393d741a5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 31 May 2021 22:53:00 +0530 Subject: [PATCH 079/213] Add link support to menu Signed-off-by: RMidhunSuresh --- src/platform/web/ui/general/Menu.js | 35 +++++++++++++++++--- src/platform/web/ui/session/room/RoomView.js | 6 ++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index 2fed5e2d..62849908 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -15,10 +15,19 @@ limitations under the License. */ import {TemplateView} from "./TemplateView.js"; +import { tag } from "./html.js"; export class Menu extends TemplateView { - static option(label, callback) { - return new MenuOption(label, callback); + static optionWithButton(label, callback) { + const option = new MenuOption(label); + option.setCallback(callback); + return option; + } + + static optionWithLink(label, link) { + const option = new MenuOption(label); + option.setLink(link); + return option; } constructor(options) { @@ -26,6 +35,15 @@ export class Menu extends TemplateView { this._options = options; } + _convertToDOM(option) { + if (option.callback) { + return tag.button({ onClick: option.callback }, option.label); + } + else if (option.link) { + return tag.a({ href: option.link }, option.label); + } + } + render(t) { return t.ul({className: "menu", role: "menu"}, this._options.map(o => { const className = { @@ -37,19 +55,26 @@ export class Menu extends TemplateView { } return t.li({ className, - }, t.button({onClick: o.callback}, o.label)); + }, this._convertToDOM(o)); })); } } class MenuOption { - constructor(label, callback) { + constructor(label) { this.label = label; - this.callback = callback; this.icon = null; this.destructive = false; } + setCallback(callback) { + this.callback = callback; + } + + setLink(link) { + this.link = link; + } + setIcon(className) { this.icon = className; return this; diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 40d7d3c4..dc8335e0 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -69,13 +69,13 @@ export class RoomView extends TemplateView { const vm = this.value; const options = []; if (vm.canLeave) { - options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); + options.push(Menu.optionWithButton(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } if (vm.canForget) { - options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); + options.push(Menu.optionWithButton(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); } if (vm.canRejoin) { - options.push(Menu.option(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); + options.push(Menu.optionWithButton(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); } if (!options.length) { return; From 00dbd3db060eb57bbcdd35ede7888984da82455b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 31 May 2021 22:54:52 +0530 Subject: [PATCH 080/213] Add menu entry to launch RoomInfoView Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 9 +++------ src/platform/web/ui/session/room/RoomView.js | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 00008039..a0028674 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -288,13 +288,10 @@ export class RoomViewModel extends ViewModel { } } - toggleDetailsPanel() { - const isPanelOpen = !!this.navigation.path.get("details"); - const link = isPanelOpen ? this.urlCreator.urlUntilSegment("room"): - this.urlCreator.urlForSegment("details"); - window.location.href = link; + get detailsLink() { + return this.urlCreator.urlForSegment("details"); } - + get composerViewModel() { return this._composerVM; } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index dc8335e0..3b775c82 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -77,6 +77,7 @@ export class RoomView extends TemplateView { if (vm.canRejoin) { options.push(Menu.optionWithButton(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); } + options.push(Menu.optionWithLink(vm.i18n`Room Details`, vm.detailsLink)) if (!options.length) { return; } From 040c744b6a69d5c9bdf0265587df2b8485ad0a03 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 1 Jun 2021 16:55:10 +0530 Subject: [PATCH 081/213] Style links and buttons similarly in menu Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 5 +++++ src/platform/web/ui/css/themes/element/theme.css | 9 ++++++--- src/platform/web/ui/general/Menu.js | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 71274a31..802ae329 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -195,6 +195,11 @@ the layout viewport up without resizing it when the keyboard shows */ z-index: 2; } +.menu .menu-item{ + box-sizing: border-box; + width: 100%; +} + .Settings { display: flex; flex-direction: column; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 8264963f..b972021a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -684,14 +684,17 @@ button.link { margin: 0; } -.menu button { +.menu li{ + margin-bottom: 10px; +} + +.menu .menu-item{ border-radius: 4px; - display: block; border: none; - width: 100%; background-color: transparent; text-align: left; padding: 8px 32px 8px 8px; + text-decoration: none; } .menu .destructive button { diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index 62849908..d7e1ce09 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -37,10 +37,10 @@ export class Menu extends TemplateView { _convertToDOM(option) { if (option.callback) { - return tag.button({ onClick: option.callback }, option.label); + return tag.button({ className: "menu-item", onClick: option.callback }, option.label); } else if (option.link) { - return tag.a({ href: option.link }, option.label); + return tag.a({ className: "menu-item", href: option.link }, option.label); } } From f1fe17fe652f0084de134fc502bb0e431a6c6ea1 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 1 Jun 2021 20:46:59 +0530 Subject: [PATCH 082/213] Make font-size equal to that of room-header Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index b972021a..d4aa695a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -813,6 +813,7 @@ button.link { .RoomName h2 { margin-bottom: 4px; + font-size: 1.8rem; } .SidebarRow { From d3d65d89baac2ca3ff44905731c9ce48b52bfe52 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 1 Jun 2021 21:06:22 +0530 Subject: [PATCH 083/213] Put sidebar rows into container Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 6 +++++- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index d4aa695a..7a536b53 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -808,7 +808,11 @@ button.link { .RoomId { color: #737D8C; font-size: 12px; - margin-bottom: 36px; +} + +.SidebarRow_collection{ + margin-top: 36px; + width: 100%; } .RoomName h2 { diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 04520602..27d97693 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -13,8 +13,11 @@ export class RoomInfoView extends TemplateView { [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), t.div({ className: "RoomName" }, [t.h2(vm.name)]), t.div({ className: "RoomId" }, [text(vm.roomId)]), - this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), - this._createSideBarRow("Encryption", encryptionString, { EncryptionStatus: true }) + t.div({ className: "SidebarRow_collection" }, + [ + this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), + this._createSideBarRow("Encryption", encryptionString, { EncryptionStatus: true }) + ]) ]); } From 32736821554ceab31cdc401bc1b63e88b9335a11 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 1 Jun 2021 21:08:07 +0530 Subject: [PATCH 084/213] Do not show room id instead of canonical alias Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 6 +++++- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index dd74e110..7170a803 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -9,7 +9,11 @@ export class RoomInfoViewModel extends ViewModel { } get roomId() { - return this._roomSummary.canonicalAlias || this._room.id; + return this._room.id; + } + + get canonicalAlias() { + return this._roomSummary.canonicalAlias; } get name() { diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 27d97693..c54cc978 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -12,7 +12,7 @@ export class RoomInfoView extends TemplateView { t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), t.div({ className: "RoomName" }, [t.h2(vm.name)]), - t.div({ className: "RoomId" }, [text(vm.roomId)]), + this._createRoomAliasDisplay(vm), t.div({ className: "SidebarRow_collection" }, [ this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), @@ -21,6 +21,11 @@ export class RoomInfoView extends TemplateView { ]); } + _createRoomAliasDisplay(vm) { + return vm.canonicalAlias ? tag.div({ className: "RoomId" }, [text(vm.canonicalAlias)]) : + ""; + } + _createSideBarRow(label, value, labelClass, valueClass) { const labelClassString = classNames({ SidebarLabel: true, ...labelClass }); const valueClassString = classNames({ SidebarValue: true, ...valueClass }); From 8d254c91e3c7859c5c026816d33b9c78140c02e3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 21:03:02 +0530 Subject: [PATCH 085/213] Undo formatting/stylistic changes Signed-off-by: RMidhunSuresh --- src/domain/session/RoomGridViewModel.js | 10 +- src/domain/session/SessionViewModel.js | 4 +- .../session/rightpanel/RoomInfoViewModel.js | 4 +- .../web/ui/css/themes/element/theme.css | 130 ++++++++---------- 4 files changed, 65 insertions(+), 83 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 8fce26ad..8610f197 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ViewModel } from "../ViewModel.js"; +import {ViewModel} from "../ViewModel.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -181,10 +181,10 @@ export class RoomGridViewModel extends ViewModel { } } -import { createNavigation } from "../navigation/index.js"; -import { ObservableValue } from "../../observable/ObservableValue.js"; +import {createNavigation} from "../navigation/index.js"; +import {ObservableValue} from "../../observable/ObservableValue.js"; -export function tests() { +export function tests() { class RoomVMMock { constructor(id) { this.id = id; @@ -200,7 +200,7 @@ export function tests() { } class RoomViewModelObservableMock extends ObservableValue { - async initialize() { } + async initialize() {} dispose() { this.get()?.dispose(); } get id() { return this.get()?.id; } } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 1ad5d50e..2c0d65bf 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -16,8 +16,8 @@ limitations under the License. */ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; -import { RoomViewModel } from "./room/RoomViewModel.js"; -import { RoomInfoViewModel } from "./rightpanel/RoomInfoViewModel.js"; +import {RoomViewModel} from "./room/RoomViewModel.js"; +import {RoomInfoViewModel} from "./rightpanel/RoomInfoViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index 7170a803..0765c293 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -1,5 +1,5 @@ -import { ViewModel } from "../../ViewModel.js"; -import { avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl } from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; export class RoomInfoViewModel extends ViewModel { constructor(options) { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 7a536b53..dd2ae1c4 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -47,37 +47,14 @@ limitations under the License. color: white; } -.hydrogen .avatar.usercolor1 { - background-color: var(--usercolor1); -} - -.hydrogen .avatar.usercolor2 { - background-color: var(--usercolor2); -} - -.hydrogen .avatar.usercolor3 { - background-color: var(--usercolor3); -} - -.hydrogen .avatar.usercolor4 { - background-color: var(--usercolor4); -} - -.hydrogen .avatar.usercolor5 { - background-color: var(--usercolor5); -} - -.hydrogen .avatar.usercolor6 { - background-color: var(--usercolor6); -} - -.hydrogen .avatar.usercolor7 { - background-color: var(--usercolor7); -} - -.hydrogen .avatar.usercolor8 { - background-color: var(--usercolor8); -} +.hydrogen .avatar.usercolor1 { background-color: var(--usercolor1); } +.hydrogen .avatar.usercolor2 { background-color: var(--usercolor2); } +.hydrogen .avatar.usercolor3 { background-color: var(--usercolor3); } +.hydrogen .avatar.usercolor4 { background-color: var(--usercolor4); } +.hydrogen .avatar.usercolor5 { background-color: var(--usercolor5); } +.hydrogen .avatar.usercolor6 { background-color: var(--usercolor6); } +.hydrogen .avatar.usercolor7 { background-color: var(--usercolor7); } +.hydrogen .avatar.usercolor8 { background-color: var(--usercolor8); } .logo { height: 48px; @@ -93,11 +70,11 @@ limitations under the License. display: flex; } -.button-row>* { +.button-row > * { margin-right: 10px; } -.button-row>*:last-child { +.button-row > *:last-child { margin-right: 0px; } @@ -196,8 +173,7 @@ a.button-action { border-radius: 16px; height: 32px; align-items: center; - padding-left: 30px; - /* 8 + 14 (icon) + 8*/ + padding-left: 30px; /* 8 + 14 (icon) + 8*/ box-sizing: border-box; } @@ -226,16 +202,13 @@ a.button-action { } .FilterField button { - width: 30px; - /* 32 - 1 (top) - 1 (bottom) */ - height: 30px; - /* 32 - 1 (top) - 1 (bottom) */ + width: 30px; /* 32 - 1 (top) - 1 (bottom) */ + height: 30px; /* 32 - 1 (top) - 1 (bottom) */ background-position: center; background-color: #e1e3e6; background-repeat: no-repeat; background-image: url('icons/clear.svg'); - border: 7px solid transparent; - /* 8 - 1 */ + border: 7px solid transparent; /* 8 - 1 */ border-radius: 100%; box-sizing: border-box; } @@ -271,11 +244,11 @@ a.button-action { padding: 12px 8px 0 8px; } -.LeftPanel> :not(:first-child) { +.LeftPanel > :not(:first-child) { margin-top: 12px; } -.LeftPanel .utilities> :not(:first-child) { +.LeftPanel .utilities > :not(:first-child) { margin-left: 8px; } @@ -295,14 +268,14 @@ a.button-action { margin-right: -8px; } -.RoomList>li { +.RoomList > li { margin: 0; padding: 4px 8px 4px 0; /* vertical align */ align-items: center; } -.RoomList>li>a { +.RoomList > li > a { text-decoration: none; /* vertical align */ align-items: center; @@ -319,7 +292,7 @@ a.button-action { border-radius: 5px; } -.RoomList li>a>* { +.RoomList li > a > * { margin-right: 8px; } @@ -366,7 +339,7 @@ a { text-align: left; } -.SessionStatusView>.end { +.SessionStatusView > .end { flex: 1; display: flex; justify-content: flex-end; @@ -392,7 +365,7 @@ a { } .SessionPickerView li { - font-size: 1.2em; + font-size: 1.2em; } .SessionPickerView .session-info { @@ -411,11 +384,11 @@ a { display: flex; } -.SessionPickerView .session-actions>* { +.SessionPickerView .session-actions > * { margin-right: 10px; } -.SessionPickerView .session-actions>*:last-child { +.SessionPickerView .session-actions > *:last-child { margin-right: 0px; } @@ -429,16 +402,16 @@ a { color: #FF4B55; } -.RoomGridView>div.container { +.RoomGridView > div.container { border-right: 1px solid rgba(245, 245, 245, 0.90); border-bottom: 1px solid rgba(245, 245, 245, 0.90); } -.RoomGridView>.focused>.room-placeholder .unfocused { +.RoomGridView > .focused > .room-placeholder .unfocused { display: none; } -.RoomGridView> :not(.focused)>.room-placeholder .focused { +.RoomGridView > :not(.focused) > .room-placeholder .focused { display: none; } @@ -446,15 +419,14 @@ a { color: #8D99A5; } -.RoomGridView>div.focus-ring { +.RoomGridView > div.focus-ring { border: 2px solid rgba(134, 193, 165, 1); border-radius: 12px; } .middle-header { box-sizing: border-box; - flex: 0 0 56px; - /* 12 + 32 + 12 to align with filter field + margin */ + flex: 0 0 56px; /* 12 + 32 + 12 to align with filter field + margin */ background: white; padding: 0 16px; border-bottom: 1px solid rgba(245, 245, 245, 0.90); @@ -465,7 +437,7 @@ a { font-weight: 600; } -.middle-header> :not(:last-child) { +.middle-header > :not(:last-child) { /* use margin-right because the first item, .close-middle might be hidden and then we don't want a margin-left on the second item*/ @@ -498,11 +470,11 @@ a { padding: 8px 16px; } -.MessageComposer> :not(:first-child) { +.MessageComposer > :not(:first-child) { margin-left: 12px; } -.MessageComposer>input { +.MessageComposer > input { padding: 0 16px; border: none; border-radius: 24px; @@ -512,7 +484,7 @@ a { font-family: "Inter", sans-serif; } -.MessageComposer>button.send { +.MessageComposer > button.send { width: 32px; height: 32px; display: block; @@ -526,7 +498,7 @@ a { background-position: center; } -.MessageComposer>button.sendFile { +.MessageComposer > button.sendFile { width: 32px; height: 32px; display: block; @@ -540,7 +512,7 @@ a { background-position: center; } -.MessageComposer>button.send:disabled { +.MessageComposer > button.send:disabled { background-color: #E3E8F0; } @@ -553,7 +525,7 @@ a { } .Settings p { - max-width: 700px; + max-width: 700px; } .Settings .row .label { @@ -575,14 +547,14 @@ a { } .Settings .row .content button { - display: inline-block; - margin: 0 8px; + display: inline-block; + margin: 0 8px; } .Settings .row .content input[type=range] { - width: 100%; - max-width: 300px; - min-width: 160px; + width: 100%; + max-width: 300px; + min-width: 160px; } .Settings .row { @@ -617,16 +589,23 @@ button.link { } .lightbox { - background-color: rgba(0, 0, 0, 0.75); + background-color: rgba(0,0,0,0.75); display: grid; - grid-template: "content close" auto "content details" 1fr / 1fr auto; + grid-template: + "content close" auto + "content details" 1fr / + 1fr auto; color: white; padding: 4px; } @media (max-aspect-ratio: 1/1) { .lightbox { - grid-template: "close" auto "content" 1fr "details" auto / 1fr; + grid-template: + "close" auto + "content" 1fr + "details" auto / + 1fr; } .lightbox .details { width: 100% !important; @@ -651,7 +630,7 @@ button.link { display: flex; } -.lightbox .loading> :not(:first-child) { +.lightbox .loading > :not(:first-child) { margin-left: 8px; } @@ -677,7 +656,7 @@ button.link { .menu { border-radius: 8px; - box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); + box-shadow: 2px 2px 10px rgba(0,0,0,0.5); padding: 4px; background-color: white; list-style: none; @@ -720,7 +699,10 @@ button.link { .InviteView_roomProfile { display: grid; gap: 4px; - grid-template: "avatar name" auto "avatar description" 1fr / 72px 1fr; + grid-template: + "avatar name" auto + "avatar description" 1fr / + 72px 1fr; align-self: center; margin-bottom: 24px; } From a3271fb9166f84a7ef0bf4c0684212e6830c795a Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 21:03:45 +0530 Subject: [PATCH 086/213] Fix Menu: Use t instead of tag to get events - Also move constructor up Signed-off-by: RMidhunSuresh --- src/platform/web/ui/general/Menu.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index d7e1ce09..8a57a7fc 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -15,9 +15,14 @@ limitations under the License. */ import {TemplateView} from "./TemplateView.js"; -import { tag } from "./html.js"; export class Menu extends TemplateView { + + constructor(options) { + super(); + this._options = options; + } + static optionWithButton(label, callback) { const option = new MenuOption(label); option.setCallback(callback); @@ -30,17 +35,12 @@ export class Menu extends TemplateView { return option; } - constructor(options) { - super(); - this._options = options; - } - - _convertToDOM(option) { + _convertToDOM(t, option) { if (option.callback) { - return tag.button({ className: "menu-item", onClick: option.callback }, option.label); + return t.button({ className: "menu-item", onClick: option.callback }, option.label); } else if (option.link) { - return tag.a({ className: "menu-item", href: option.link }, option.label); + return t.a({ className: "menu-item", href: option.link }, option.label); } } @@ -55,7 +55,7 @@ export class Menu extends TemplateView { } return t.li({ className, - }, this._convertToDOM(o)); + }, this._convertToDOM(t, o)); })); } } From 98ef6f432183ffbdc263339909b4d11a851cb9b5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 21:05:54 +0530 Subject: [PATCH 087/213] Use optionWithButton Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 60b39048..4ecde2ba 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -93,9 +93,9 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; if (vm.canAbortSending) { - options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); + options.push(Menu.optionWithButton(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { - options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); + options.push(Menu.optionWithButton(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options; } From 65bd74442f598a4770611584b94b9907485f6963 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 22:34:13 +0530 Subject: [PATCH 088/213] Make menu-items uniform - Same font ,font-size and height. Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index dd2ae1c4..6d747e96 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -674,6 +674,10 @@ button.link { text-align: left; padding: 8px 32px 8px 8px; text-decoration: none; + font-size: 1.6rem; + height: 24px; + display: block; + cursor: pointer; } .menu .destructive button { From 9074caf443589dd99fca0a657db236f18c1d0b18 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 22:39:11 +0530 Subject: [PATCH 089/213] Change Room Details --> Room details Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 3b775c82..7ecf73b1 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -77,7 +77,7 @@ export class RoomView extends TemplateView { if (vm.canRejoin) { options.push(Menu.optionWithButton(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); } - options.push(Menu.optionWithLink(vm.i18n`Room Details`, vm.detailsLink)) + options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsLink)) if (!options.length) { return; } From 986f04aac022fa0c27fdf3c310e3ff25fa9466a9 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 22:42:23 +0530 Subject: [PATCH 090/213] Move Room details menu entry to top Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 7ecf73b1..c08d4b72 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,6 +68,7 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; + options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsLink)) if (vm.canLeave) { options.push(Menu.optionWithButton(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } @@ -77,7 +78,6 @@ export class RoomView extends TemplateView { if (vm.canRejoin) { options.push(Menu.optionWithButton(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); } - options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsLink)) if (!options.length) { return; } From a3587a80c6fa0fa2ba79b5efd39d055076bb370b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 22:56:49 +0530 Subject: [PATCH 091/213] Use internationalization for user facing strings Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index c54cc978..1e047401 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -5,7 +5,7 @@ import { AvatarView } from "../../avatar.js"; export class RoomInfoView extends TemplateView { render(t, vm) { - const encryptionString = vm.isEncrypted ? "On" : "Off"; + const encryptionString = vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; return t.div({ className: "RoomInfo" }, [ this._createButton(vm), @@ -15,8 +15,8 @@ export class RoomInfoView extends TemplateView { this._createRoomAliasDisplay(vm), t.div({ className: "SidebarRow_collection" }, [ - this._createSideBarRow("People", vm.memberCount, { MemberCount: true }), - this._createSideBarRow("Encryption", encryptionString, { EncryptionStatus: true }) + this._createSideBarRow(vm.i18n`People`, vm.memberCount, { MemberCount: true }), + this._createSideBarRow(vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) ]) ]); } From 04933acbfbff32767389f5844d7c3f3be356af32 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 23:14:17 +0530 Subject: [PATCH 092/213] Add missing checks Signed-off-by: RMidhunSuresh --- src/domain/session/leftpanel/LeftPanelViewModel.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 96becf2a..eee53512 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -99,7 +99,9 @@ export class LeftPanelViewModel extends ViewModel { const room = this.navigation.path.get("room"); if (room) { path = path.with(room); - path = path.with(details); + if (details) { + path = path.with(details); + } } this.navigation.applyPath(path); } else { @@ -108,7 +110,9 @@ export class LeftPanelViewModel extends ViewModel { if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); - path = path.with(details); + if (details) { + path = path.with(details); + } } else { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); From 7f922afe790bcacb9e95fed3b532deb51eeb901b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Jun 2021 23:18:45 +0530 Subject: [PATCH 093/213] Reduce duplication in code Signed-off-by: RMidhunSuresh --- src/domain/session/leftpanel/LeftPanelViewModel.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index eee53512..c1696d26 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -93,20 +93,17 @@ export class LeftPanelViewModel extends ViewModel { } toggleGrid() { + const room = this.navigation.path.get("room"); const details = this.navigation.path.get("details"); + let path = this.navigation.path.until("session"); if (this.gridEnabled) { - let path = this.navigation.path.until("session"); - const room = this.navigation.path.get("room"); if (room) { path = path.with(room); if (details) { path = path.with(details); } } - this.navigation.applyPath(path); } else { - let path = this.navigation.path.until("session"); - const room = this.navigation.path.get("room"); if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); @@ -117,8 +114,8 @@ export class LeftPanelViewModel extends ViewModel { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); } - this.navigation.applyPath(path); } + this.navigation.applyPath(path); } get tileViewModels() { From a4bcb21a85afcfbc95b3cb8197c2db49d9f537e5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 12:43:03 +0530 Subject: [PATCH 094/213] Move details handling to separate function Signed-off-by: RMidhunSuresh --- src/domain/session/leftpanel/LeftPanelViewModel.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index c1696d26..9f7b85ab 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -92,24 +92,24 @@ export class LeftPanelViewModel extends ViewModel { } } + _pathForDetails(path) { + const details = this.navigation.path.get("details"); + return details ? path.with(details) : path; + } + toggleGrid() { const room = this.navigation.path.get("room"); - const details = this.navigation.path.get("details"); let path = this.navigation.path.until("session"); if (this.gridEnabled) { if (room) { path = path.with(room); - if (details) { - path = path.with(details); - } + path = this._pathForDetails(path); } } else { if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); - if (details) { - path = path.with(details); - } + path = this._pathForDetails(path); } else { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); From 37e052c0617fef4b4c396ab3f5f7e878267775cb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 13:15:55 +0530 Subject: [PATCH 095/213] details do not need to be child of rooms Signed-off-by: RMidhunSuresh --- src/domain/navigation/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 5e1a1b62..899fc53a 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -35,7 +35,7 @@ function allowsChild(parent, child) { return type === "room" || type === "rooms" || type === "settings"; case "rooms": // downside of the approach: both of these will control which tile is selected - return type === "room" || type === "empty-grid-tile" || type === "details"; + return type === "room" || type === "empty-grid-tile"; case "room": return type === "lightbox" || type === "details"; default: From b2448e1207932146a4ba98bd4ab0946012d9cee4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 13:21:49 +0530 Subject: [PATCH 096/213] Calculate path when button is clicked Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 5 +++-- src/platform/web/ui/session/rightpanel/RoomInfoView.js | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index 0765c293..4525838c 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -44,7 +44,8 @@ export class RoomInfoViewModel extends ViewModel { return this.name; } - get closeLink() { - return this.urlCreator.urlUntilSegment("room"); + closePanel() { + const path = this.navigation.path.until("room"); + this.navigation.applyPath(path); } } diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index 1e047401..fd2ba58e 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -8,7 +8,7 @@ export class RoomInfoView extends TemplateView { const encryptionString = vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; return t.div({ className: "RoomInfo" }, [ - this._createButton(vm), + this._createButton(t, vm), t.div({ className: "RoomAvatar" }, [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), t.div({ className: "RoomName" }, [t.h2(vm.name)]), @@ -40,8 +40,10 @@ export class RoomInfoView extends TemplateView { [tag.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]) } - _createButton(vm) { - return tag.div({ className: "buttons" }, - [tag.a({ className: "close button-utility", href: vm.closeLink })]); + _createButton(t, vm) { + return t.div({ className: "buttons" }, + [ + t.button({ className: "close button-utility", onClick: () => vm.closePanel() }) + ]); } } From 5b740389129dcd147b1ede41a0b4b1d7769e437a Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 14:27:43 +0530 Subject: [PATCH 097/213] Check value of details everywhere Signed-off-by: RMidhunSuresh --- src/domain/session/RoomGridViewModel.js | 2 +- src/domain/session/SessionViewModel.js | 2 +- src/domain/session/leftpanel/LeftPanelViewModel.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 8610f197..c2608680 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -84,7 +84,7 @@ export class RoomGridViewModel extends ViewModel { } const vmo = this._viewModelsObservables[index]; if (vmo) { - const detailsShown = !!this.navigation.path.get("details"); + const detailsShown = !!this.navigation.path.get("details")?.value; this.navigation.push("room", vmo.id); if (detailsShown) { this.navigation.push("details", true); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2c0d65bf..decc0b86 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -260,7 +260,7 @@ export class SessionViewModel extends ViewModel { _toggleRoomInformationPanel() { this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); const room = this._roomFromNavigation(); - const enable = !!this.navigation.path.get("details"); + const enable = !!this.navigation.path.get("details")?.value; if (enable) { this._roomInfoViewModel = this.track(new RoomInfoViewModel(this.childOptions({ room }))); } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 9f7b85ab..061c640c 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -94,7 +94,7 @@ export class LeftPanelViewModel extends ViewModel { _pathForDetails(path) { const details = this.navigation.path.get("details"); - return details ? path.with(details) : path; + return details?.value ? path.with(details) : path; } toggleGrid() { From 0d63ce9a3a1d6e12d52335329725108697695baa Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 14:43:48 +0530 Subject: [PATCH 098/213] Change SideBarActive --> right-shown Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 8 ++++---- src/platform/web/ui/session/SessionView.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 802ae329..38e7155e 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -54,7 +54,7 @@ main { min-width: 0; } -.SideBarActive{ +.right-shown{ grid-template: "status status status" auto "left middle right" 1fr / @@ -72,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { - .SessionView:not(.middle-shown, .SideBarActive) { + .SessionView:not(.middle-shown, .right-shown) { grid-template: "status" auto "left" 1fr / @@ -86,7 +86,7 @@ the layout viewport up without resizing it when the keyboard shows */ 1fr; } - .SessionView.SideBarActive{ + .SessionView.right-shown{ grid-template: "status" auto "right" 1fr / @@ -96,7 +96,7 @@ the layout viewport up without resizing it when the keyboard shows */ .SessionView:not(.middle-shown) .room-placeholder { display: none; } .SessionView.middle-shown .LeftPanel { display: none; } - .SessionView.SideBarActive .middle { display: none; } + .SessionView.right-shown .middle { display: none; } /* show back button */ .middle .close-middle { display: block !important; } diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 2c2a3cd1..a6830fa7 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -33,7 +33,7 @@ export class SessionView extends TemplateView { className: { "SessionView": true, "middle-shown": vm => !!vm.activeMiddleViewModel, - "SideBarActive": vm => !!vm.roomInfoViewModel + "right-shown": vm => !!vm.roomInfoViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), From 4c962943b2442d82eeb6c0f620cb2cf6127a9377 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 14:54:53 +0530 Subject: [PATCH 099/213] Modify setters in MenuOption to support chaining Signed-off-by: RMidhunSuresh --- src/platform/web/ui/general/Menu.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index 8a57a7fc..baf7b1a9 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -69,10 +69,12 @@ class MenuOption { setCallback(callback) { this.callback = callback; + return this; } setLink(link) { this.link = link; + return this; } setIcon(className) { From 4700009c68beae5c4f68b8d9f17d96682e0c78d6 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 16:01:33 +0530 Subject: [PATCH 100/213] Add new test for parseUrlPath Make sure that the details panel stays open during room change. Signed-off-by: RMidhunSuresh --- src/domain/navigation/index.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 899fc53a..fab91c11 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -257,6 +257,25 @@ export function tests() { assert.equal(segments[2].type, "room"); assert.equal(segments[2].value, "a"); }, + "parse open-room action changing focus to an existing room with details open": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b"), + new Segment("details", true) + ]); + const segments = parseUrlPath("/session/1/open-room/a", path); + assert.equal(segments.length, 4); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "a"); + assert.equal(segments[3].type, "details"); + assert.equal(segments[3].value, true); + }, "parse open-room action setting a room in an empty tile": assert => { const nav = new Navigation(allowsChild); const path = nav.pathFrom([ From c6f3b1fbbe0834ffaffa5130bc6d03b96d5a20df Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 16:42:39 +0530 Subject: [PATCH 101/213] Do not use private props from room summary Signed-off-by: RMidhunSuresh --- src/domain/session/rightpanel/RoomInfoViewModel.js | 9 ++++----- src/matrix/room/BaseRoom.js | 8 ++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index 4525838c..d45c4994 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -5,7 +5,6 @@ export class RoomInfoViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; - this._roomSummary = this._room._summary._data; } get roomId() { @@ -13,19 +12,19 @@ export class RoomInfoViewModel extends ViewModel { } get canonicalAlias() { - return this._roomSummary.canonicalAlias; + return this._room.canonicalAlias; } get name() { - return this._roomSummary.name || this._room._heroes?._roomName || this._roomSummary.canonicalAlias; + return this._room.name; } get isEncrypted() { - return !!this._roomSummary.encryption; + return !!this._room.isEncrypted; } get memberCount() { - return this._roomSummary.joinCount; + return this._room.joinedMemberCount; } get avatarLetter() { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 531f6a1a..a6a59278 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -354,6 +354,14 @@ export class BaseRoom extends EventEmitter { return this.membership === "leave"; } + get canonicalAlias() { + return this._summary.data.canonicalAlias; + } + + get joinedMemberCount() { + return this._summary.data.joinCount; + } + get mediaRepository() { return this._mediaRepository; } From 04065847dc0ded1dc188164c0a1da62ff9bb40f6 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 16:47:15 +0530 Subject: [PATCH 102/213] Remove unused getter Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a0028674..16d9b4c0 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -104,10 +104,6 @@ export class RoomViewModel extends ViewModel { get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } - get roomDetailsLink() { - return this.urlCreator.urlForSegment("details"); - } - get error() { if (this._timelineError) { return `Something went wrong loading the timeline: ${this._timelineError.message}`; From fcc2afba08d0a69399188f5a4f89829389b175a5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 17:58:44 +0530 Subject: [PATCH 103/213] Do not compute link in getter Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 16d9b4c0..47bc0c7b 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -36,6 +36,7 @@ export class RoomViewModel extends ViewModel { } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._detailsLink = this.urlCreator.urlForSegment("details"); } async load() { @@ -103,6 +104,7 @@ export class RoomViewModel extends ViewModel { get id() { return this._room.id; } get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } + get detailsLink() { return this._detailsLink; } get error() { if (this._timelineError) { @@ -284,10 +286,6 @@ export class RoomViewModel extends ViewModel { } } - get detailsLink() { - return this.urlCreator.urlForSegment("details"); - } - get composerViewModel() { return this._composerVM; } From eb870cfc23d1b09577fc04e728918ae0cb54a222 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 18:04:07 +0530 Subject: [PATCH 104/213] Use url instead of link Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 4 ++-- src/platform/web/ui/session/room/RoomView.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 47bc0c7b..60cd0055 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -36,7 +36,7 @@ export class RoomViewModel extends ViewModel { } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); - this._detailsLink = this.urlCreator.urlForSegment("details"); + this._detailsUrl = this.urlCreator.urlForSegment("details"); } async load() { @@ -104,7 +104,7 @@ export class RoomViewModel extends ViewModel { get id() { return this._room.id; } get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } - get detailsLink() { return this._detailsLink; } + get detailsUrl() { return this._detailsUrl; } get error() { if (this._timelineError) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index c08d4b72..c1a1ed74 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,7 +68,7 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; - options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsLink)) + options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsUrl)) if (vm.canLeave) { options.push(Menu.optionWithButton(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } From 7fbcf8953984b8bfc3d06f189e6297fb14185ccf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 18:29:12 +0530 Subject: [PATCH 105/213] Remove unncessary empty line Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index decc0b86..21b88e8a 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -84,7 +84,6 @@ export class SessionViewModel extends ViewModel { const details = this.navigation.observe("details"); this.track(details.subscribe(() => this._toggleRoomInformationPanel())); this._toggleRoomInformationPanel(); - } get id() { From 332fbdda846b4e046f741fdcc4a3a7a459519864 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 18:37:32 +0530 Subject: [PATCH 106/213] Move variable to scope of conditional Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 21b88e8a..ab59c2af 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -258,9 +258,9 @@ export class SessionViewModel extends ViewModel { _toggleRoomInformationPanel() { this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); - const room = this._roomFromNavigation(); const enable = !!this.navigation.path.get("details")?.value; if (enable) { + const room = this._roomFromNavigation(); this._roomInfoViewModel = this.track(new RoomInfoViewModel(this.childOptions({ room }))); } this.emitChange("roomInfoViewModel"); From 008f3601ca187ff4fbd1d8475fa8cf995e3da8ad Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 4 Jun 2021 23:19:25 +0530 Subject: [PATCH 107/213] Remove link support from Menu - Not needed anymore since every link item has been rewritten as a button. Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 8 +++- .../web/ui/css/themes/element/theme.css | 2 - src/platform/web/ui/general/Menu.js | 39 +++---------------- src/platform/web/ui/session/room/RoomView.js | 8 ++-- .../session/room/timeline/BaseMessageView.js | 4 +- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 60cd0055..ef103255 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -36,7 +36,6 @@ export class RoomViewModel extends ViewModel { } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); - this._detailsUrl = this.urlCreator.urlForSegment("details"); } async load() { @@ -104,7 +103,6 @@ export class RoomViewModel extends ViewModel { get id() { return this._room.id; } get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } - get detailsUrl() { return this._detailsUrl; } get error() { if (this._timelineError) { @@ -289,6 +287,12 @@ export class RoomViewModel extends ViewModel { get composerViewModel() { return this._composerVM; } + + toggleDetailsPanel() { + let path = this.navigation.path.until("room"); + path = path.with(this.navigation.segment("details", true)); + this.navigation.applyPath(path); + } } class ComposerViewModel extends ViewModel { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 6d747e96..4a397d13 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -673,10 +673,8 @@ button.link { background-color: transparent; text-align: left; padding: 8px 32px 8px 8px; - text-decoration: none; font-size: 1.6rem; height: 24px; - display: block; cursor: pointer; } diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index baf7b1a9..34a90c09 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -17,33 +17,15 @@ limitations under the License. import {TemplateView} from "./TemplateView.js"; export class Menu extends TemplateView { + static option(label, callback) { + return new MenuOption(label, callback); + } constructor(options) { super(); this._options = options; } - static optionWithButton(label, callback) { - const option = new MenuOption(label); - option.setCallback(callback); - return option; - } - - static optionWithLink(label, link) { - const option = new MenuOption(label); - option.setLink(link); - return option; - } - - _convertToDOM(t, option) { - if (option.callback) { - return t.button({ className: "menu-item", onClick: option.callback }, option.label); - } - else if (option.link) { - return t.a({ className: "menu-item", href: option.link }, option.label); - } - } - render(t) { return t.ul({className: "menu", role: "menu"}, this._options.map(o => { const className = { @@ -55,28 +37,19 @@ export class Menu extends TemplateView { } return t.li({ className, - }, this._convertToDOM(t, o)); + }, t.button({className:"menu-item", onClick: o.callback}, o.label)); })); } } class MenuOption { - constructor(label) { + constructor(label, callback) { this.label = label; + this.callback = callback; this.icon = null; this.destructive = false; } - setCallback(callback) { - this.callback = callback; - return this; - } - - setLink(link) { - this.link = link; - return this; - } - setIcon(className) { this.icon = className; return this; diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index c1a1ed74..13259a08 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,15 +68,15 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; - options.push(Menu.optionWithLink(vm.i18n`Room details`, vm.detailsUrl)) + options.push(Menu.option(vm.i18n`Room details`, () => vm.toggleDetailsPanel())) if (vm.canLeave) { - options.push(Menu.optionWithButton(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); + options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } if (vm.canForget) { - options.push(Menu.optionWithButton(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); + options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); } if (vm.canRejoin) { - options.push(Menu.optionWithButton(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); + options.push(Menu.option(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); } if (!options.length) { return; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 4ecde2ba..60b39048 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -93,9 +93,9 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; if (vm.canAbortSending) { - options.push(Menu.optionWithButton(vm.i18n`Cancel`, () => vm.abortSending())); + options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { - options.push(Menu.optionWithButton(vm.i18n`Delete`, () => vm.redact()).setDestructive()); + options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options; } From efd37d811f34f4d48c82d36770be552a49da04dc Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 6 Jun 2021 23:18:16 +0530 Subject: [PATCH 108/213] Update on changes to vm Signed-off-by: RMidhunSuresh --- .../session/rightpanel/RoomInfoViewModel.js | 1 + .../web/ui/session/rightpanel/RoomInfoView.js | 34 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomInfoViewModel.js index d45c4994..281c557b 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomInfoViewModel.js @@ -5,6 +5,7 @@ export class RoomInfoViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; + this._room.on("change", () => this.emitChange()); } get roomId() { diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index fd2ba58e..c70454e7 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -5,18 +5,20 @@ import { AvatarView } from "../../avatar.js"; export class RoomInfoView extends TemplateView { render(t, vm) { - const encryptionString = vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; - + const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; return t.div({ className: "RoomInfo" }, [ this._createButton(t, vm), t.div({ className: "RoomAvatar" }, - [t.view(new AvatarView(vm, 52)), this._createEncryptionIcon(vm.isEncrypted)]), - t.div({ className: "RoomName" }, [t.h2(vm.name)]), + [ + t.view(new AvatarView(vm, 52)), + t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionAvatarView(isEncrypted)) + ]), + t.div({ className: "RoomName" }, [t.h2(vm => vm.name)]), this._createRoomAliasDisplay(vm), t.div({ className: "SidebarRow_collection" }, [ - this._createSideBarRow(vm.i18n`People`, vm.memberCount, { MemberCount: true }), - this._createSideBarRow(vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) + this._createSideBarRow(t, vm.i18n`People`, vm => vm.memberCount, { MemberCount: true }), + this._createSideBarRow(t, vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) ]) ]); } @@ -26,20 +28,15 @@ export class RoomInfoView extends TemplateView { ""; } - _createSideBarRow(label, value, labelClass, valueClass) { + _createSideBarRow(t, label, value, labelClass, valueClass) { const labelClassString = classNames({ SidebarLabel: true, ...labelClass }); const valueClassString = classNames({ SidebarValue: true, ...valueClass }); - return tag.div({ className: "SidebarRow" }, [ - tag.div({ className: labelClassString }, [text(label)]), - tag.div({ className: valueClassString }, [text(value)]) + return t.div({ className: "SidebarRow" }, [ + t.div({ className: labelClassString }, [text(label)]), + t.div({ className: valueClassString }, value) ]); } - _createEncryptionIcon(isEncrypted) { - return tag.div({ className: "RoomEncryption" }, - [tag.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]) - } - _createButton(t, vm) { return t.div({ className: "buttons" }, [ @@ -47,3 +44,10 @@ export class RoomInfoView extends TemplateView { ]); } } + +class EncryptionAvatarView extends TemplateView{ + render(t, isEncrypted) { + return t.div({ className: "RoomEncryption" }, + [t.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]); + } +} From f917730a21c7446e317f08cd12e5c88c7c92cac9 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 6 Jun 2021 23:52:24 +0530 Subject: [PATCH 109/213] Stick to naming convention for css/html classes Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/right-panel.css | 14 ++++---- .../web/ui/css/themes/element/theme.css | 28 ++++++++-------- .../web/ui/session/rightpanel/RoomInfoView.js | 32 +++++++++---------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 19b45596..f3f34e38 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -1,30 +1,30 @@ -.RoomInfo { +.RoomDetailsView { grid-area: right; flex-direction: column; } -.RoomAvatar { +.RoomDetailsView_avatar { display: flex; } -.RoomName h2 { +.RoomDetailsView_name h2 { text-align: center; } -.SidebarRow { +.RoomDetailsView_row { justify-content: space-between; } -.SidebarLabel, .SidebarRow, .RoomInfo, .RoomEncryption { +.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView { display: flex; align-items: center; } -.RoomEncryption { +.EncryptionIconView { justify-content: center; } -.buttons { +.RoomDetailsView_buttons { display: flex; justify-content: flex-end; width: 100%; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 4a397d13..f41570d4 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -780,44 +780,44 @@ button.link { /* Right Panel */ -.RoomInfo { +.RoomDetailsView { background: rgba(245, 245, 245, 0.90); padding: 10px; } -.RoomAvatar { +.RoomDetailsView_avatar { margin-top: 44px; } -.RoomId { +.RoomDetailsView_id { color: #737D8C; font-size: 12px; } -.SidebarRow_collection{ +.RoomDetailsView_rows{ margin-top: 36px; width: 100%; } -.RoomName h2 { +.RoomDetailsView_name h2 { margin-bottom: 4px; font-size: 1.8rem; } -.SidebarRow { +.RoomDetailsView_row { width: 90%; margin-bottom: 20px; font-weight: 500; font-size: 15px; } -.SidebarLabel::before { +.RoomDetailsView_label::before { padding-right: 16px; height: 20px; width: 20px; } -.SidebarValue { +.RoomDetailsView_value { color: #737D8C; } @@ -831,7 +831,7 @@ button.link { /* Encryption icon next to avatar */ -.RoomEncryption { +.EncryptionIconView { width: 52px; height: 52px; border-radius: 100%; @@ -840,24 +840,24 @@ button.link { margin-left: -16px; } -.encrypted, .unencrypted { +.EncryptionIconView_encrypted, .EncryptionIconView_unencrypted { height: 24px; width: 24px; } -.encrypted { +.EncryptionIconView_encrypted { content: url("./icons/e2ee-normal.svg"); } -.unencrypted { +.EncryptionIconView_unencrypted { content: url("./icons/e2ee-disabled.svg"); } -.RoomInfo .button-utility { +.RoomDetailsView .button-utility { width: 24px; height: 24px; } -.RoomInfo .close { +.RoomDetailsView .close { background-image: url("./icons/clear.svg"); } diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomInfoView.js index c70454e7..43a0f994 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomInfoView.js @@ -6,48 +6,48 @@ export class RoomInfoView extends TemplateView { render(t, vm) { const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; - return t.div({ className: "RoomInfo" }, [ + return t.div({ className: "RoomDetailsView" }, [ this._createButton(t, vm), - t.div({ className: "RoomAvatar" }, + t.div({ className: "RoomDetailsView_avatar" }, [ t.view(new AvatarView(vm, 52)), - t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionAvatarView(isEncrypted)) + t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionIconView(isEncrypted)) ]), - t.div({ className: "RoomName" }, [t.h2(vm => vm.name)]), + t.div({ className: "RoomDetailsView_name" }, [t.h2(vm => vm.name)]), this._createRoomAliasDisplay(vm), - t.div({ className: "SidebarRow_collection" }, + t.div({ className: "RoomDetailsView_rows" }, [ - this._createSideBarRow(t, vm.i18n`People`, vm => vm.memberCount, { MemberCount: true }), - this._createSideBarRow(t, vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) + this._createRightPanelRow(t, vm.i18n`People`, vm => vm.memberCount, { MemberCount: true }), + this._createRightPanelRow(t, vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) ]) ]); } _createRoomAliasDisplay(vm) { - return vm.canonicalAlias ? tag.div({ className: "RoomId" }, [text(vm.canonicalAlias)]) : + return vm.canonicalAlias ? tag.div({ className: "RoomDetailsView_id" }, [text(vm.canonicalAlias)]) : ""; } - _createSideBarRow(t, label, value, labelClass, valueClass) { - const labelClassString = classNames({ SidebarLabel: true, ...labelClass }); - const valueClassString = classNames({ SidebarValue: true, ...valueClass }); - return t.div({ className: "SidebarRow" }, [ + _createRightPanelRow(t, label, value, labelClass, valueClass) { + const labelClassString = classNames({ RoomDetailsView_label: true, ...labelClass }); + const valueClassString = classNames({ RoomDetailsView_value: true, ...valueClass }); + return t.div({ className: "RoomDetailsView_row" }, [ t.div({ className: labelClassString }, [text(label)]), t.div({ className: valueClassString }, value) ]); } _createButton(t, vm) { - return t.div({ className: "buttons" }, + return t.div({ className: "RoomDetailsView_buttons" }, [ t.button({ className: "close button-utility", onClick: () => vm.closePanel() }) ]); } } -class EncryptionAvatarView extends TemplateView{ +class EncryptionIconView extends TemplateView{ render(t, isEncrypted) { - return t.div({ className: "RoomEncryption" }, - [t.div({ className: isEncrypted ? "encrypted" : "unencrypted" })]); + return t.div({ className: "EncryptionIconView" }, + [t.div({ className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted" })]); } } From f3f1436bb01c47a1d176d6cc9475a59f4a626ac3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 00:19:31 +0530 Subject: [PATCH 110/213] Rename room info to room details everywhere Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 20 +++++++++---------- ...foViewModel.js => RoomDetailsViewModel.js} | 2 +- src/platform/web/ui/session/SessionView.js | 6 +++--- .../{RoomInfoView.js => RoomDetailsView.js} | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) rename src/domain/session/rightpanel/{RoomInfoViewModel.js => RoomDetailsViewModel.js} (95%) rename src/platform/web/ui/session/rightpanel/{RoomInfoView.js => RoomDetailsView.js} (97%) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index ab59c2af..e5755a21 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; -import {RoomInfoViewModel} from "./rightpanel/RoomInfoViewModel.js"; +import {RoomDetailsViewModel} from "./rightpanel/RoomDetailsViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; @@ -63,7 +63,7 @@ export class SessionViewModel extends ViewModel { if (!this._gridViewModel) { this._updateRoom(roomId); } - this._toggleRoomInformationPanel(); + this._toggleRoomDetailsPanel(); })); if (!this._gridViewModel) { this._updateRoom(currentRoomId.get()); @@ -82,8 +82,8 @@ export class SessionViewModel extends ViewModel { this._updateLightbox(lightbox.get()); const details = this.navigation.observe("details"); - this.track(details.subscribe(() => this._toggleRoomInformationPanel())); - this._toggleRoomInformationPanel(); + this.track(details.subscribe(() => this._toggleRoomDetailsPanel())); + this._toggleRoomDetailsPanel(); } get id() { @@ -118,8 +118,8 @@ export class SessionViewModel extends ViewModel { return this._roomViewModelObservable?.get(); } - get roomInfoViewModel() { - return this._roomInfoViewModel; + get roomDetailsViewModel() { + return this._roomDetailsViewModel; } _updateGrid(roomIds) { @@ -256,14 +256,14 @@ export class SessionViewModel extends ViewModel { return room; } - _toggleRoomInformationPanel() { - this._roomInfoViewModel = this.disposeTracked(this._roomInfoViewModel); + _toggleRoomDetailsPanel() { + this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel); const enable = !!this.navigation.path.get("details")?.value; if (enable) { const room = this._roomFromNavigation(); - this._roomInfoViewModel = this.track(new RoomInfoViewModel(this.childOptions({ room }))); + this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({ room }))); } - this.emitChange("roomInfoViewModel"); + this.emitChange("roomDetailsViewModel"); } } diff --git a/src/domain/session/rightpanel/RoomInfoViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js similarity index 95% rename from src/domain/session/rightpanel/RoomInfoViewModel.js rename to src/domain/session/rightpanel/RoomDetailsViewModel.js index 281c557b..6430b922 100644 --- a/src/domain/session/rightpanel/RoomInfoViewModel.js +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -1,7 +1,7 @@ import {ViewModel} from "../../ViewModel.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; -export class RoomInfoViewModel extends ViewModel { +export class RoomDetailsViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index a6830fa7..31d5befa 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -25,7 +25,7 @@ import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; import {SettingsView} from "./settings/SettingsView.js"; -import { RoomInfoView } from "./rightpanel/RoomInfoView.js"; +import { RoomDetailsView } from "./rightpanel/RoomDetailsView.js"; export class SessionView extends TemplateView { render(t, vm) { @@ -33,7 +33,7 @@ export class SessionView extends TemplateView { className: { "SessionView": true, "middle-shown": vm => !!vm.activeMiddleViewModel, - "right-shown": vm => !!vm.roomInfoViewModel + "right-shown": vm => !!vm.roomDetailsViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), @@ -55,7 +55,7 @@ export class SessionView extends TemplateView { return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } }), - t.mapView(vm => vm.roomInfoViewModel, roomInfoViewModel => roomInfoViewModel ? new RoomInfoView(roomInfoViewModel) : null), + t.mapView(vm => vm.roomDetailsViewModel, roomDetailsViewModel => roomDetailsViewModel ? new RoomDetailsView(roomDetailsViewModel) : null), t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) ]); } diff --git a/src/platform/web/ui/session/rightpanel/RoomInfoView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js similarity index 97% rename from src/platform/web/ui/session/rightpanel/RoomInfoView.js rename to src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 43a0f994..3bbeb2da 100644 --- a/src/platform/web/ui/session/rightpanel/RoomInfoView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -2,7 +2,7 @@ import { TemplateView } from "../../general/TemplateView.js"; import { text, classNames, tag } from "../../general/html.js"; import { AvatarView } from "../../avatar.js"; -export class RoomInfoView extends TemplateView { +export class RoomDetailsView extends TemplateView { render(t, vm) { const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; From 2c0176f3f2c13639d7b051c901ed0b15f1063617 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 00:23:38 +0530 Subject: [PATCH 111/213] Remove unwanted width Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f41570d4..e21c8be0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -805,7 +805,6 @@ button.link { } .RoomDetailsView_row { - width: 90%; margin-bottom: 20px; font-weight: 500; font-size: 15px; From 6086d157e2fe190d38b6d515b963201cdb29aeeb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 00:24:29 +0530 Subject: [PATCH 112/213] Make value multiple of 4 Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index e21c8be0..ed426865 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -782,7 +782,7 @@ button.link { .RoomDetailsView { background: rgba(245, 245, 245, 0.90); - padding: 10px; + padding: 16px; } .RoomDetailsView_avatar { From 95512b51442497a7f1d7eb9ea31af4bfa3797bbf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 00:26:18 +0530 Subject: [PATCH 113/213] Remove margin from top Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index ed426865..dd09b0f1 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -785,10 +785,6 @@ button.link { padding: 16px; } -.RoomDetailsView_avatar { - margin-top: 44px; -} - .RoomDetailsView_id { color: #737D8C; font-size: 12px; From fa67c5e248a08b4a72264638250cd9f25ff7882f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 13:39:58 +0530 Subject: [PATCH 114/213] Refactor function - Move related parameters closer together. - Remove unused parameter. Signed-off-by: RMidhunSuresh --- .../web/ui/session/rightpanel/RoomDetailsView.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 3bbeb2da..503ebf49 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -17,8 +17,8 @@ export class RoomDetailsView extends TemplateView { this._createRoomAliasDisplay(vm), t.div({ className: "RoomDetailsView_rows" }, [ - this._createRightPanelRow(t, vm.i18n`People`, vm => vm.memberCount, { MemberCount: true }), - this._createRightPanelRow(t, vm.i18n`Encryption`, encryptionString, { EncryptionStatus: true }) + this._createRightPanelRow(t, vm.i18n`People`, { MemberCount: true }, vm => vm.memberCount), + this._createRightPanelRow(t, vm.i18n`Encryption`, { EncryptionStatus: true }, encryptionString) ]) ]); } @@ -28,12 +28,11 @@ export class RoomDetailsView extends TemplateView { ""; } - _createRightPanelRow(t, label, value, labelClass, valueClass) { + _createRightPanelRow(t, label, labelClass, value) { const labelClassString = classNames({ RoomDetailsView_label: true, ...labelClass }); - const valueClassString = classNames({ RoomDetailsView_value: true, ...valueClass }); return t.div({ className: "RoomDetailsView_row" }, [ t.div({ className: labelClassString }, [text(label)]), - t.div({ className: valueClassString }, value) + t.div({ className: "RoomDetailsView_value" }, value) ]); } From 4005d70bb96f5bba0e61761a2482f8ca4a37d15d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 13:45:33 +0530 Subject: [PATCH 115/213] Increase height to make element more centered Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index dd09b0f1..b0847b37 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -808,7 +808,7 @@ button.link { .RoomDetailsView_label::before { padding-right: 16px; - height: 20px; + height: 24px; width: 20px; } From dd9a19b7f0d7853b4a4e2aeb059be72ed3bf9563 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 14:53:36 +0530 Subject: [PATCH 116/213] Make formatting consistent Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomDetailsView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 503ebf49..8e33f5b9 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -44,9 +44,9 @@ export class RoomDetailsView extends TemplateView { } } -class EncryptionIconView extends TemplateView{ +class EncryptionIconView extends TemplateView { render(t, isEncrypted) { - return t.div({ className: "EncryptionIconView" }, + return t.div({ className: "EncryptionIconView" }, [t.div({ className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted" })]); } } From 0c5d118bfd71a698b95e49a44f8ba95bc43fe84c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 15:30:50 +0530 Subject: [PATCH 117/213] Add rule to warn on wrong formatting Signed-off-by: RMidhunSuresh --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index 2a14eac6..90c0f3fd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,5 +13,6 @@ module.exports = { "no-empty": "off", "no-prototype-builtins": "off", "no-unused-vars": "warn", + "object-curly-spacing": ["warn", "never"] } }; From b2f2d51594067c503f5ae3fa15a258a0ad9bb586 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 15:33:55 +0530 Subject: [PATCH 118/213] Make formatting consistent with the codebase Signed-off-by: RMidhunSuresh --- .../ui/session/rightpanel/RoomDetailsView.js | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 8e33f5b9..3b8f0077 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -1,52 +1,51 @@ -import { TemplateView } from "../../general/TemplateView.js"; -import { text, classNames, tag } from "../../general/html.js"; -import { AvatarView } from "../../avatar.js"; +import {TemplateView} from "../../general/TemplateView.js"; +import {text, classNames, tag} from "../../general/html.js"; +import {AvatarView} from "../../avatar.js"; export class RoomDetailsView extends TemplateView { - render(t, vm) { const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; - return t.div({ className: "RoomDetailsView" }, [ + return t.div({className: "RoomDetailsView"}, [ this._createButton(t, vm), - t.div({ className: "RoomDetailsView_avatar" }, + t.div({className: "RoomDetailsView_avatar"}, [ t.view(new AvatarView(vm, 52)), t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionIconView(isEncrypted)) ]), - t.div({ className: "RoomDetailsView_name" }, [t.h2(vm => vm.name)]), + t.div({className: "RoomDetailsView_name"}, [t.h2(vm => vm.name)]), this._createRoomAliasDisplay(vm), - t.div({ className: "RoomDetailsView_rows" }, + t.div({className: "RoomDetailsView_rows"}, [ - this._createRightPanelRow(t, vm.i18n`People`, { MemberCount: true }, vm => vm.memberCount), - this._createRightPanelRow(t, vm.i18n`Encryption`, { EncryptionStatus: true }, encryptionString) + this._createRightPanelRow(t, vm.i18n`People`, {MemberCount: true}, vm => vm.memberCount), + this._createRightPanelRow(t, vm.i18n`Encryption`, {EncryptionStatus: true}, encryptionString) ]) ]); } _createRoomAliasDisplay(vm) { - return vm.canonicalAlias ? tag.div({ className: "RoomDetailsView_id" }, [text(vm.canonicalAlias)]) : + return vm.canonicalAlias ? tag.div({className: "RoomDetailsView_id"}, [text(vm.canonicalAlias)]) : ""; } _createRightPanelRow(t, label, labelClass, value) { - const labelClassString = classNames({ RoomDetailsView_label: true, ...labelClass }); - return t.div({ className: "RoomDetailsView_row" }, [ - t.div({ className: labelClassString }, [text(label)]), - t.div({ className: "RoomDetailsView_value" }, value) + const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass}); + return t.div({className: "RoomDetailsView_row"}, [ + t.div({className: labelClassString}, [text(label)]), + t.div({className: "RoomDetailsView_value"}, value) ]); } _createButton(t, vm) { - return t.div({ className: "RoomDetailsView_buttons" }, + return t.div({className: "RoomDetailsView_buttons"}, [ - t.button({ className: "close button-utility", onClick: () => vm.closePanel() }) + t.button({className: "close button-utility", onClick: () => vm.closePanel()}) ]); } } class EncryptionIconView extends TemplateView { render(t, isEncrypted) { - return t.div({ className: "EncryptionIconView" }, - [t.div({ className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted" })]); + return t.div({className: "EncryptionIconView"}, + [t.div({className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted"})]); } } From e2443a8b09bebc1ea187c9d1c0f634e5885bfe8a Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 7 Jun 2021 16:09:13 +0530 Subject: [PATCH 119/213] Undo spillover from earlier formatting havoc Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/avatar.css | 1 - src/platform/web/ui/css/layout.css | 1 - src/platform/web/ui/css/themes/element/theme.css | 11 +++++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index b2405896..d369f85f 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -37,7 +37,6 @@ limitations under the License. } /* work around postcss-css-variables limitations and repeat variable usage */ - .hydrogen .avatar.size-128 { --avatar-size: 128px; width: var(--avatar-size); diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 38e7155e..413d53ec 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -91,7 +91,6 @@ the layout viewport up without resizing it when the keyboard shows */ "status" auto "right" 1fr / 1fr; - } .SessionView:not(.middle-shown) .room-placeholder { display: none; } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index b0847b37..c22ab79b 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -65,15 +65,12 @@ limitations under the License. } /** buttons */ - .button-row { display: flex; } - .button-row > * { margin-right: 10px; } - .button-row > *:last-child { margin-right: 0px; } @@ -109,6 +106,7 @@ a.button-action { display: block; } + .button-action.secondary { color: #03B381; } @@ -181,7 +179,6 @@ a.button-action { border: 1px #e1e3e6 solid; background-color: white; } - .FilterField:focus-within button { border-color: white; } @@ -387,7 +384,6 @@ a { .SessionPickerView .session-actions > * { margin-right: 10px; } - .SessionPickerView .session-actions > *:last-child { margin-right: 0px; } @@ -419,6 +415,7 @@ a { color: #8D99A5; } + .RoomGridView > div.focus-ring { border: 2px solid rgba(134, 193, 165, 1); border-radius: 12px; @@ -492,6 +489,7 @@ a { border: none; text-indent: 200%; overflow: hidden; + background-color: #03B381; background-image: url('icons/send.svg'); background-repeat: no-repeat; @@ -594,7 +592,7 @@ button.link { grid-template: "content close" auto "content details" 1fr / - 1fr auto; + 1fr auto; color: white; padding: 4px; } @@ -607,6 +605,7 @@ button.link { "details" auto / 1fr; } + .lightbox .details { width: 100% !important; } From cb051ad161aa2a781fb233c220d7a7d6554937ef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Jun 2021 16:52:30 +0200 Subject: [PATCH 120/213] WIP3 --- src/matrix/room/timeline/Timeline.js | 48 +++++++++++++++++++++------ src/platform/web/dom/request/fetch.js | 3 ++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 806c86e3..bac47124 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -23,6 +23,7 @@ import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; import {getRelationFromContent, getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; +import {REDACTION_TYPE} from "../common.js"; export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { @@ -125,13 +126,17 @@ export class Timeline { return params; }); if (redactedEntry) { - const redactedRelation = getRelationFromContent(redactedEntry.content); - if (redactedRelation?.event_id) { - const found = this._remoteEntries.findAndUpdate( - e => e.id === redactedRelation.event_id, - relationTarget => relationTarget.addLocalRelation(redactedEntry) || false - ); - } + this._addLocallyRedactedRelationToTarget(redactedEntry); + } + } + + _addLocallyRedactedRelationToTarget(redactedEntry) { + const redactedRelation = getRelationFromContent(redactedEntry.content); + if (redactedRelation?.event_id) { + const found = this._remoteEntries.findAndUpdate( + e => e.id === redactedRelation.event_id, + relationTarget => relationTarget.addLocalRelation(redactedEntry) || false + ); } } @@ -180,7 +185,6 @@ export class Timeline { } } - async getOwnAnnotationEntry(targetId, key) { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.timelineEvents, @@ -227,9 +231,33 @@ export class Timeline { for (const pee of this._localEntries) { // this will work because we set relatedEventId when removing remote echos if (pee.relatedEventId) { + + const relationTarget = entries.find(e => e.id === pee.relatedEventId); - // no need to emit here as this entry is about to be added - relationTarget?.addLocalRelation(pee); + if (relationTarget) { + const wasRedacted = relationTarget.isRedacted; + // no need to emit here as this entry is about to be added + relationTarget.addLocalRelation(pee); + if (!wasRedacted && relationTarget.isRedacted) { + this._addLocallyRedactedRelationToTarget(relationTarget); + } + } else if (pee.eventType === REDACTION_TYPE) { + // if pee is a redaction, we need to lookup the event it is redacting, + // and see if that is a relation of one of the entries + const redactedEntry = this.getByEventId(pee.relatedEventId); + if (redactedEntry) { + const relation = getRelation(redactedEntry); + if (relation) { + const redactedRelationTarget = entries.find(e => e.id === relation.event_id); + redactedRelationTarget?.addLocalRelation(redactedEntry); + } + } + } else { + // TODO: errors are swallowed here + // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => !["m.reaction", "m.room.redaction"].includes(e.eventType)).map(e => `${e.id}: ${e.content?.body}`).join(",")); + // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => "m.reaction" === e.eventType).map(e => `${e.id}: ${getRelation(e)?.key}`).join(",")); + // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.map(e => `${e.id}: ${e._eventEntry.key.substr(e._eventEntry.key.lastIndexOf("|") + 1)}`).join(",")); + } } } } diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 66f1a148..e75bb4be 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -103,6 +103,8 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } options.headers = fetchHeaders; } + const promise = Promise.reject(new ConnectionError()); + /* const promise = fetch(url, options).then(async response => { const {status} = response; let body; @@ -135,6 +137,7 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } throw err; }); + */ const result = new RequestResult(promise, controller); if (timeout) { From 757e08c62cf5d052d698fcc7f6cf3de762b637de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Jun 2021 18:29:10 +0200 Subject: [PATCH 121/213] WIP 4 --- .../room/timeline/tiles/BaseMessageTile.js | 10 +- src/matrix/room/BaseRoom.js | 17 -- .../room/timeline/PendingAnnotations.js | 33 ++-- src/matrix/room/timeline/Timeline.js | 145 +++++++---------- .../room/timeline/entries/BaseEventEntry.js | 42 +++-- .../timeline/entries/PendingEventEntry.js | 22 ++- src/observable/index.js | 1 + src/observable/list/AsyncMappedList.js | 150 ++++++++++++++++++ src/observable/list/BaseMappedList.js | 77 +++++++++ src/observable/list/MappedList.js | 52 +----- .../web/ui/css/themes/element/timeline.css | 2 +- .../web/ui/session/room/TimelineList.js | 1 + 12 files changed, 374 insertions(+), 178 deletions(-) create mode 100644 src/observable/list/AsyncMappedList.js create mode 100644 src/observable/list/BaseMappedList.js diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index d4bbcbaa..3c20f7f7 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -125,8 +125,7 @@ export class BaseMessageTile extends SimpleTile { react(key, log = null) { return this.logger.wrapOrRun(log, "react", async log => { - const existingAnnotation = await this._entry.getOwnAnnotationEntry(this._timeline, key); - const redaction = existingAnnotation?.pendingRedaction; + const redaction = this._entry.getAnnotationPendingRedaction(key); if (redaction && !redaction.pendingEvent.hasStartedSending) { log.set("abort_redaction", true); await redaction.pendingEvent.abort(); @@ -138,9 +137,16 @@ export class BaseMessageTile extends SimpleTile { redactReaction(key, log = null) { return this.logger.wrapOrRun(log, "redactReaction", async log => { + const redaction = this._entry.getAnnotationPendingRedaction(key); + if (redaction) { + log.set("already_redacting", true); + return; + } const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); if (entry) { await this._room.sendRedaction(entry.id, null, log); + } else { + log.set("no_reaction", true); } }); } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 42dc6162..b1d3f7fc 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -452,23 +452,6 @@ export class BaseRoom extends EventEmitter { return observable; } - async getOwnAnnotationEntry(targetId, key) { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.timelineEvents, - this._storage.storeNames.timelineRelations, - ]); - const relations = await txn.timelineRelations.getForTargetAndType(this.id, targetId, ANNOTATION_RELATION_TYPE); - for (const relation of relations) { - const annotation = await txn.timelineEvents.getByEventId(this.id, relation.sourceEventId); - if (annotation.event.sender === this._user.id && getRelation(annotation.event).key === key) { - const eventEntry = new EventEntry(annotation, this._fragmentIdComparer); - // add local relations - return eventEntry; - } - } - return null; - } - async _readEventById(eventId) { let stores = [this._storage.storeNames.timelineEvents]; if (this.isEncrypted) { diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js index cb85aa5b..38034a00 100644 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -19,35 +19,33 @@ import {getRelationFromContent} from "./relations.js"; export class PendingAnnotations { constructor() { this.aggregatedAnnotations = new Map(); + // this contains both pending annotation entries, and pending redactions of remote annotation entries this._entries = []; } /** adds either a pending annotation entry, or a remote annotation entry with a pending redaction */ - add(annotationEntry) { - const relation = getRelationFromContent(annotationEntry.content); - const key = relation.key; + add(entry) { + const {key} = entry.ownOrRedactedRelation; if (!key) { return; } const count = this.aggregatedAnnotations.get(key) || 0; - const addend = annotationEntry.isRedacted ? -1 : 1; - console.log("add", count, addend); + const addend = entry.isRedaction ? -1 : 1; this.aggregatedAnnotations.set(key, count + addend); - this._entries.push(annotationEntry); + this._entries.push(entry); } /** removes either a pending annotation entry, or a remote annotation entry with a pending redaction */ - remove(annotationEntry) { - const idx = this._entries.indexOf(annotationEntry); + remove(entry) { + const idx = this._entries.indexOf(entry); if (idx === -1) { return; } this._entries.splice(idx, 1); - const relation = getRelationFromContent(annotationEntry.content); - const key = relation.key; + const {key} = entry.ownOrRedactedRelation; let count = this.aggregatedAnnotations.get(key); if (count !== undefined) { - const addend = annotationEntry.isRedacted ? 1 : -1; + const addend = entry.isRedaction ? 1 : -1; count += addend; if (count <= 0) { this.aggregatedAnnotations.delete(key); @@ -60,13 +58,22 @@ export class PendingAnnotations { findForKey(key) { return this._entries.find(e => { const relation = getRelationFromContent(e.content); - if (relation.key === key) { + if (relation && relation.key === key) { + return e; + } + }); + } + + findRedactionForKey(key) { + return this._entries.find(e => { + const relation = e.redactingRelation; + if (relation && relation.key === key) { return e; } }); } get isEmpty() { - return this._entries.length; + return this._entries.length === 0; } } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index bac47124..1dc5213a 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js"; +import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index.js"; import {Disposables} from "../../../utils/Disposables.js"; import {Direction} from "./Direction.js"; import {TimelineReader} from "./persistence/TimelineReader.js"; @@ -101,85 +101,65 @@ export class Timeline { _setupEntries(timelineEntries) { this._remoteEntries.setManySorted(timelineEntries); if (this._pendingEvents) { - this._localEntries = new MappedList(this._pendingEvents, pe => { - const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock}); - this._onAddPendingEvent(pee); - return pee; - }, (pee, params) => { - // is sending but redacted, who do we detect that here to remove the relation? - pee.notifyUpdate(params); - }, pee => this._onRemovePendingEvent(pee)); + this._localEntries = new AsyncMappedList(this._pendingEvents, + pe => this._mapPendingEventToEntry(pe), + (pee, params) => { + // is sending but redacted, who do we detect that here to remove the relation? + pee.notifyUpdate(params); + }, + pee => this._applyAndEmitLocalRelationChange(pee, target => target.removeLocalRelation(pee)) + ); } else { this._localEntries = new ObservableArray(); } this._allEntries = new ConcatList(this._remoteEntries, this._localEntries); } - _onAddPendingEvent(pee) { - let redactedEntry; - this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => { - const wasRedacted = target.isRedacted; - const params = target.addLocalRelation(pee); - if (!wasRedacted && target.isRedacted) { - redactedEntry = target; - } - return params; - }); - if (redactedEntry) { - this._addLocallyRedactedRelationToTarget(redactedEntry); + async _mapPendingEventToEntry(pe) { + // we load the remote redaction target for pending events, + // so if we are redacting a relation, we can pass the redaction + // to the relation target and the removal of the relation can + // be taken into account for local echo. + let redactionTarget; + if (pe.eventType === REDACTION_TYPE && pe.relatedEventId) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineEvents, + ]); + const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, pe.relatedEventId); + redactionTarget = redactionTargetEntry?.event; } + const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock, redactionTarget}); + this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee)); + return pee; } - _addLocallyRedactedRelationToTarget(redactedEntry) { - const redactedRelation = getRelationFromContent(redactedEntry.content); - if (redactedRelation?.event_id) { - const found = this._remoteEntries.findAndUpdate( - e => e.id === redactedRelation.event_id, - relationTarget => relationTarget.addLocalRelation(redactedEntry) || false - ); - } - } - _onRemovePendingEvent(pee) { - let unredactedEntry; - this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => { - const wasRedacted = target.isRedacted; - const params = target.removeLocalRelation(pee); - if (wasRedacted && !target.isRedacted) { - unredactedEntry = target; - } - return params; - }); - if (unredactedEntry) { - const redactedRelation = getRelationFromContent(unredactedEntry.content); - if (redactedRelation?.event_id) { - this._remoteEntries.findAndUpdate( - e => e.id === redactedRelation.event_id, - relationTarget => relationTarget.removeLocalRelation(unredactedEntry) || false - ); - } - } - } - - _applyAndEmitLocalRelationChange(pe, updater) { + _applyAndEmitLocalRelationChange(pee, updater) { const updateOrFalse = e => { const params = updater(e); return params ? params : false; }; + let found = false; + const {relatedTxnId} = pee.pendingEvent; // first, look in local entries based on txn id - if (pe.relatedTxnId) { - const found = this._localEntries.findAndUpdate( - e => e.id === pe.relatedTxnId, + if (relatedTxnId) { + found = this._localEntries.findAndUpdate( + e => e.id === relatedTxnId, updateOrFalse, ); - if (found) { - return; - } } // now look in remote entries based on event id - if (pe.relatedEventId) { + if (!found && pee.relatedEventId) { this._remoteEntries.findAndUpdate( - e => e.id === pe.relatedEventId, + e => e.id === pee.relatedEventId, + updateOrFalse + ); + } + // also look for a relation target to update with this redaction + if (pee.redactingRelation) { + const eventId = pee.redactingRelation.event_id; + const found = this._remoteEntries.findAndUpdate( + e => e.id === eventId, updateOrFalse ); } @@ -231,32 +211,17 @@ export class Timeline { for (const pee of this._localEntries) { // this will work because we set relatedEventId when removing remote echos if (pee.relatedEventId) { - - const relationTarget = entries.find(e => e.id === pee.relatedEventId); if (relationTarget) { - const wasRedacted = relationTarget.isRedacted; // no need to emit here as this entry is about to be added relationTarget.addLocalRelation(pee); - if (!wasRedacted && relationTarget.isRedacted) { - this._addLocallyRedactedRelationToTarget(relationTarget); - } - } else if (pee.eventType === REDACTION_TYPE) { - // if pee is a redaction, we need to lookup the event it is redacting, - // and see if that is a relation of one of the entries - const redactedEntry = this.getByEventId(pee.relatedEventId); - if (redactedEntry) { - const relation = getRelation(redactedEntry); - if (relation) { - const redactedRelationTarget = entries.find(e => e.id === relation.event_id); - redactedRelationTarget?.addLocalRelation(redactedEntry); - } - } - } else { - // TODO: errors are swallowed here - // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => !["m.reaction", "m.room.redaction"].includes(e.eventType)).map(e => `${e.id}: ${e.content?.body}`).join(",")); - // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => "m.reaction" === e.eventType).map(e => `${e.id}: ${getRelation(e)?.key}`).join(",")); - // console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.map(e => `${e.id}: ${e._eventEntry.key.substr(e._eventEntry.key.lastIndexOf("|") + 1)}`).join(",")); + } + } + if (pee.redactingRelation) { + const eventId = pee.redactingRelation.event_id; + const relationTarget = entries.find(e => e.id === eventId); + if (relationTarget) { + relationTarget.addLocalRelation(pee); } } } @@ -344,6 +309,7 @@ export class Timeline { } import {FragmentIdComparer} from "./FragmentIdComparer.js"; +import {poll} from "../../../mocks/poll.js"; import {Clock as MockClock} from "../../../mocks/Clock.js"; import {createMockStorage} from "../../../mocks/Storage.js"; import {createEvent, withTextBody, withSender} from "../../../mocks/event.js"; @@ -355,6 +321,14 @@ import {PendingEvent} from "../sending/PendingEvent.js"; export function tests() { const fragmentIdComparer = new FragmentIdComparer([]); const roomId = "$abc"; + const noopHandler = {}; + noopHandler.onAdd = + noopHandler.onUpdate = + noopHandler.onRemove = + noopHandler.onMove = + noopHandler.onReset = + function() {}; + return { "adding or replacing entries before subscribing to entries does not loose local relations": async assert => { const pendingEvents = new ObservableArray(); @@ -384,10 +358,11 @@ export function tests() { content: {}, relatedEventId: event2.event_id }})); - // 4. subscribe (it's now safe to iterate timeline.entries) - timeline.entries.subscribe({}); + // 4. subscribe (it's now safe to iterate timeline.entries) + timeline.entries.subscribe(noopHandler); // 5. check the local relation got correctly aggregated - assert.equal(Array.from(timeline.entries)[0].isRedacting, true); + const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting); + assert.equal(locallyRedacted, true); } } } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 09132a21..8c449f33 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -34,6 +34,10 @@ export class BaseEventEntry extends BaseEntry { return this.isRedacting; } + get isRedaction() { + return this.eventType === REDACTION_TYPE; + } + get redactionReason() { if (this._pendingRedactions) { return this._pendingRedactions[0].content?.reason; @@ -46,7 +50,7 @@ export class BaseEventEntry extends BaseEntry { @return [string] returns the name of the field that has changed, if any */ addLocalRelation(entry) { - if (entry.eventType === REDACTION_TYPE) { + if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id) { if (!this._pendingRedactions) { this._pendingRedactions = []; } @@ -55,23 +59,25 @@ export class BaseEventEntry extends BaseEntry { return "isRedacted"; } } else { - const relation = getRelationFromContent(entry.content); - if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE) { - if (!this._pendingAnnotations) { - this._pendingAnnotations = new PendingAnnotations(); + const relation = entry.ownOrRedactedRelation; + if (relation && relation.event_id === this.id) { + if (relation.rel_type === ANNOTATION_RELATION_TYPE) { + if (!this._pendingAnnotations) { + this._pendingAnnotations = new PendingAnnotations(); + } + this._pendingAnnotations.add(entry); + return "pendingAnnotations"; } - this._pendingAnnotations.add(entry); - return "pendingAnnotations"; } } } /** - deaggregates local relation. + deaggregates local relation or a local redaction of a remote relation. @return [string] returns the name of the field that has changed, if any */ removeLocalRelation(entry) { - if (entry.eventType === REDACTION_TYPE && this._pendingRedactions) { + if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id && this._pendingRedactions) { const countBefore = this._pendingRedactions.length; this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry); if (this._pendingRedactions.length === 0) { @@ -81,13 +87,15 @@ export class BaseEventEntry extends BaseEntry { } } } else { - const relation = getRelationFromContent(entry.content); - if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { - this._pendingAnnotations.remove(entry); - if (this._pendingAnnotations.isEmpty) { - this._pendingAnnotations = null; + const relation = entry.ownOrRedactedRelation; + if (relation && relation.event_id === this.id) { + if (relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { + this._pendingAnnotations.remove(entry); + if (this._pendingAnnotations.isEmpty) { + this._pendingAnnotations = null; + } + return "pendingAnnotations"; } - return "pendingAnnotations"; } } } @@ -120,4 +128,8 @@ export class BaseEventEntry extends BaseEntry { async getOwnAnnotationEntry(timeline, key) { return this._pendingAnnotations?.findForKey(key); } + + getAnnotationPendingRedaction(key) { + return this._pendingAnnotations?.findRedactionForKey(key); + } } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 77f6da93..69cdcedc 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -16,14 +16,16 @@ limitations under the License. import {PENDING_FRAGMENT_ID} from "./BaseEntry.js"; import {BaseEventEntry} from "./BaseEventEntry.js"; +import {getRelationFromContent} from "../relations.js"; export class PendingEventEntry extends BaseEventEntry { - constructor({pendingEvent, member, clock}) { + constructor({pendingEvent, member, clock, redactionTarget}) { super(null); this._pendingEvent = pendingEvent; /** @type {RoomMember} */ this._member = member; this._clock = clock; + this._redactionTarget = redactionTarget; } get fragmentId() { @@ -86,6 +88,24 @@ export class PendingEventEntry extends BaseEventEntry { return this._pendingEvent.relatedEventId; } + get redactingRelation() { + if (this._redactionTarget) { + return getRelationFromContent(this._redactionTarget.content); + } + } + /** + * returns either the relationship on this entry, + * or the relationship this entry is redacting. + * + * Useful while aggregating relations for local echo. */ + get ownOrRedactedRelation() { + if (this._redactionTarget) { + return getRelationFromContent(this._redactionTarget.content); + } else { + return getRelationFromContent(this._pendingEvent.content); + } + } + getOwnAnnotationId(_, key) { // TODO: implement this once local reactions are implemented return null; diff --git a/src/observable/index.js b/src/observable/index.js index 351c25b8..4c455407 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -23,6 +23,7 @@ import {BaseObservableMap} from "./map/BaseObservableMap.js"; export { ObservableArray } from "./list/ObservableArray.js"; export { SortedArray } from "./list/SortedArray.js"; export { MappedList } from "./list/MappedList.js"; +export { AsyncMappedList } from "./list/AsyncMappedList.js"; export { ConcatList } from "./list/ConcatList.js"; export { ObservableMap } from "./map/ObservableMap.js"; diff --git a/src/observable/list/AsyncMappedList.js b/src/observable/list/AsyncMappedList.js new file mode 100644 index 00000000..12ef3c42 --- /dev/null +++ b/src/observable/list/AsyncMappedList.js @@ -0,0 +1,150 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2021 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 {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js"; + +export class AsyncMappedList extends BaseMappedList { + constructor(sourceList, mapper, updater, removeCallback) { + super(sourceList, mapper, updater, removeCallback); + this._eventQueue = null; + } + + onSubscribeFirst() { + this._sourceUnsubscribe = this._sourceList.subscribe(this); + this._eventQueue = []; + this._mappedValues = []; + let idx = 0; + for (const item of this._sourceList) { + this._eventQueue.push(new AddEvent(idx, item)); + idx += 1; + } + this._flush(); + } + + async _flush() { + if (this._flushing) { + return; + } + this._flushing = true; + try { + while (this._eventQueue.length) { + const event = this._eventQueue.shift(); + await event.run(this); + } + } finally { + this._flushing = false; + } + } + + onReset() { + if (this._eventQueue) { + this._eventQueue.push(new ResetEvent()); + this._flush(); + } + } + + onAdd(index, value) { + if (this._eventQueue) { + this._eventQueue.push(new AddEvent(index, value)); + this._flush(); + } + } + + onUpdate(index, value, params) { + if (this._eventQueue) { + this._eventQueue.push(new UpdateEvent(index, value, params)); + this._flush(); + } + } + + onRemove(index) { + if (this._eventQueue) { + this._eventQueue.push(new RemoveEvent(index)); + this._flush(); + } + } + + onMove(fromIdx, toIdx) { + if (this._eventQueue) { + this._eventQueue.push(new MoveEvent(fromIdx, toIdx)); + this._flush(); + } + } + + onUnsubscribeLast() { + this._sourceUnsubscribe(); + this._eventQueue = null; + this._mappedValues = null; + } +} + +class AddEvent { + constructor(index, value) { + this.index = index; + this.value = value; + } + + async run(list) { + const mappedValue = await list._mapper(this.value); + runAdd(list, this.index, mappedValue); + } +} + +class UpdateEvent { + constructor(index, value, params) { + this.index = index; + this.value = value; + this.params = params; + } + + async run(list) { + runUpdate(list, this.index, this.value, this.params); + } +} + +class RemoveEvent { + constructor(index) { + this.index = index; + } + + async run(list) { + runRemove(list, this.index); + } +} + +class MoveEvent { + constructor(fromIdx, toIdx) { + this.fromIdx = fromIdx; + this.toIdx = toIdx; + } + + async run(list) { + runMove(list, this.fromIdx, this.toIdx); + } +} + +class ResetEvent { + async run(list) { + runReset(list); + } +} + +export function tests() { + return { + + } +} diff --git a/src/observable/list/BaseMappedList.js b/src/observable/list/BaseMappedList.js new file mode 100644 index 00000000..1ccd4e12 --- /dev/null +++ b/src/observable/list/BaseMappedList.js @@ -0,0 +1,77 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2021 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 {BaseObservableList} from "./BaseObservableList.js"; +import {findAndUpdateInArray} from "./common.js"; + +export class BaseMappedList extends BaseObservableList { + constructor(sourceList, mapper, updater, removeCallback) { + super(); + this._sourceList = sourceList; + this._mapper = mapper; + this._updater = updater; + this._removeCallback = removeCallback; + this._mappedValues = null; + this._sourceUnsubscribe = null; + } + + findAndUpdate(predicate, updater) { + return findAndUpdateInArray(predicate, this._mappedValues, this, updater); + } + + get length() { + return this._mappedValues.length; + } + + [Symbol.iterator]() { + return this._mappedValues.values(); + } +} + +export function runAdd(list, index, mappedValue) { + list._mappedValues.splice(index, 0, mappedValue); + list.emitAdd(index, mappedValue); +} + +export function runUpdate(list, index, value, params) { + const mappedValue = list._mappedValues[index]; + if (list._updater) { + list._updater(mappedValue, params, value); + } + list.emitUpdate(index, mappedValue, params); +} + +export function runRemove(list, index) { + const mappedValue = list._mappedValues[index]; + list._mappedValues.splice(index, 1); + if (list._removeCallback) { + list._removeCallback(mappedValue); + } + list.emitRemove(index, mappedValue); +} + +export function runMove(list, fromIdx, toIdx) { + const mappedValue = list._mappedValues[fromIdx]; + list._mappedValues.splice(fromIdx, 1); + list._mappedValues.splice(toIdx, 0, mappedValue); + list.emitMove(fromIdx, toIdx, mappedValue); +} + +export function runReset(list) { + list._mappedValues = []; + list.emitReset(); +} diff --git a/src/observable/list/MappedList.js b/src/observable/list/MappedList.js index ddb61384..096a018f 100644 --- a/src/observable/list/MappedList.js +++ b/src/observable/list/MappedList.js @@ -15,20 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableList} from "./BaseObservableList.js"; -import {findAndUpdateInArray} from "./common.js"; - -export class MappedList extends BaseObservableList { - constructor(sourceList, mapper, updater, removeCallback) { - super(); - this._sourceList = sourceList; - this._mapper = mapper; - this._updater = updater; - this._removeCallback = removeCallback; - this._sourceUnsubscribe = null; - this._mappedValues = null; - } +import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js"; +export class MappedList extends BaseMappedList { onSubscribeFirst() { this._sourceUnsubscribe = this._sourceList.subscribe(this); this._mappedValues = []; @@ -38,14 +27,12 @@ export class MappedList extends BaseObservableList { } onReset() { - this._mappedValues = []; - this.emitReset(); + runReset(this); } onAdd(index, value) { const mappedValue = this._mapper(value); - this._mappedValues.splice(index, 0, mappedValue); - this.emitAdd(index, mappedValue); + runAdd(this, index, mappedValue); } onUpdate(index, value, params) { @@ -53,47 +40,24 @@ export class MappedList extends BaseObservableList { if (!this._mappedValues) { return; } - const mappedValue = this._mappedValues[index]; - if (this._updater) { - this._updater(mappedValue, params, value); - } - this.emitUpdate(index, mappedValue, params); + runUpdate(this, index, value, params); } onRemove(index) { - const mappedValue = this._mappedValues[index]; - this._mappedValues.splice(index, 1); - if (this._removeCallback) { - this._removeCallback(mappedValue); - } - this.emitRemove(index, mappedValue); + runRemove(this, index); } onMove(fromIdx, toIdx) { - const mappedValue = this._mappedValues[fromIdx]; - this._mappedValues.splice(fromIdx, 1); - this._mappedValues.splice(toIdx, 0, mappedValue); - this.emitMove(fromIdx, toIdx, mappedValue); + runMove(this, fromIdx, toIdx); } onUnsubscribeLast() { this._sourceUnsubscribe(); } - - findAndUpdate(predicate, updater) { - return findAndUpdateInArray(predicate, this._mappedValues, this, updater); - } - - get length() { - return this._mappedValues.length; - } - - [Symbol.iterator]() { - return this._mappedValues.values(); - } } import {ObservableArray} from "./ObservableArray.js"; +import {BaseObservableList} from "./BaseObservableList.js"; export async function tests() { class MockList extends BaseObservableList { diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index b2a425ff..af5fb041 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -243,7 +243,7 @@ only loads when the top comes into view*/ .Timeline_messageReactions button.haveReacted.isPending { animation-name: glow-reaction-border; - animation-duration: 0.8s; + animation-duration: 0.5s; animation-direction: alternate; animation-iteration-count: infinite; animation-timing-function: linear; diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index a0ad1c83..74556c57 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -74,6 +74,7 @@ export class TimelineList extends ListView { } } catch (err) { + console.error(err); //ignore error, as it is handled in the VM } finally { From 1d9709d4e314a0ed89bf76a63e9bd4ddd161791c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 11 Jun 2021 11:02:31 +0200 Subject: [PATCH 122/213] also compare by key if the timestamps are the same --- src/domain/session/room/timeline/ReactionsViewModel.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 6a813d03..b7426b49 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -135,7 +135,12 @@ class ReactionViewModel { const a = this._annotation; const b = other._annotation; if (a && b) { - return a.firstTimestamp - b.firstTimestamp; + const cmp = a.firstTimestamp - b.firstTimestamp; + if (cmp === 0) { + return this.key < other.key ? -1 : 1; + } else { + return cmp; + } } else if (a) { return -1; } else { From 81a721f88025450cf5b6cd81fc3ac787a8e0c9d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 11 Jun 2021 11:04:48 +0200 Subject: [PATCH 123/213] make equality stable in comparator for reaction --- src/domain/session/room/timeline/ReactionsViewModel.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index b7426b49..83757c13 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -129,6 +129,12 @@ class ReactionViewModel { } _compare(other) { + // the comparator is also used to test for equality, if the comparison returns 0 + // given that the firstTimestamp isn't set anymore when the last reaction is removed, + // the remove event wouldn't be able to find the correct index anymore. So special case equality. + if (other === this) { + return 0; + } if (this.count !== other.count) { return other.count - this.count; } else { From 6bdbbee83effb734ca9ff2e110d4607e85f35f43 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 11 Jun 2021 11:05:34 +0200 Subject: [PATCH 124/213] undo forced offline mode --- src/platform/web/dom/request/fetch.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index e75bb4be..66f1a148 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -103,8 +103,6 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } options.headers = fetchHeaders; } - const promise = Promise.reject(new ConnectionError()); - /* const promise = fetch(url, options).then(async response => { const {status} = response; let body; @@ -137,7 +135,6 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } throw err; }); - */ const result = new RequestResult(promise, controller); if (timeout) { From 75ee5093610420c4961803e8baa1855059ecf166 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 11 Jun 2021 11:30:11 +0200 Subject: [PATCH 125/213] fix lint --- src/domain/session/room/timeline/ReactionsViewModel.js | 1 - src/matrix/room/BaseRoom.js | 1 - src/matrix/room/timeline/Timeline.js | 4 ++-- src/matrix/room/timeline/entries/BaseEventEntry.js | 2 +- src/matrix/room/timeline/entries/PendingEventEntry.js | 6 +----- src/platform/web/ui/session/room/timeline/ReactionsView.js | 4 ++-- 6 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 83757c13..6364fc8b 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -13,7 +13,6 @@ 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 {ViewModel} from "../../../ViewModel.js"; import {ObservableMap} from "../../../../observable/map/ObservableMap.js"; export class ReactionsViewModel { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index b1d3f7fc..8df281fd 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -28,7 +28,6 @@ import {EventEntry} from "./timeline/entries/EventEntry.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; import {DecryptionSource} from "../e2ee/common.js"; import {ensureLogItem} from "../../logging/utils.js"; -import {ANNOTATION_RELATION_TYPE, getRelation} from "./timeline/relations.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 1dc5213a..35593663 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; -import {getRelationFromContent, getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; +import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {REDACTION_TYPE} from "../common.js"; export class Timeline { @@ -158,7 +158,7 @@ export class Timeline { // also look for a relation target to update with this redaction if (pee.redactingRelation) { const eventId = pee.redactingRelation.event_id; - const found = this._remoteEntries.findAndUpdate( + this._remoteEntries.findAndUpdate( e => e.id === eventId, updateOrFalse ); diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 8c449f33..6c37457a 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,7 +16,7 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; -import {createAnnotation, getRelationFromContent, ANNOTATION_RELATION_TYPE} from "../relations.js"; +import {createAnnotation, ANNOTATION_RELATION_TYPE} from "../relations.js"; import {PendingAnnotations} from "../PendingAnnotations.js"; export class BaseEventEntry extends BaseEntry { diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 69cdcedc..d9aa4b80 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -92,6 +92,7 @@ export class PendingEventEntry extends BaseEventEntry { if (this._redactionTarget) { return getRelationFromContent(this._redactionTarget.content); } + return null; } /** * returns either the relationship on this entry, @@ -105,9 +106,4 @@ export class PendingEventEntry extends BaseEventEntry { return getRelationFromContent(this._pendingEvent.content); } } - - getOwnAnnotationId(_, key) { - // TODO: implement this once local reactions are implemented - return null; - } } diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 33a34c9f..15d1574a 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -23,7 +23,7 @@ export class ReactionsView extends ListView { className: "Timeline_messageReactions", tagName: "div", list: reactionsViewModel.reactions, - onItemClick: (reactionView, evt) => reactionView.onClick(), + onItemClick: reactionView => reactionView.onClick(), } super(options, reactionVM => new ReactionView(reactionVM)); } @@ -40,4 +40,4 @@ class ReactionView extends TemplateView { onClick() { this.value.toggleReaction(); } -} \ No newline at end of file +} From e10b455b2737cde833ae34627822466b31b58fad Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 14 Jun 2021 20:32:05 +0530 Subject: [PATCH 126/213] Rename method Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index e5755a21..12f80f8b 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -63,7 +63,7 @@ export class SessionViewModel extends ViewModel { if (!this._gridViewModel) { this._updateRoom(roomId); } - this._toggleRoomDetailsPanel(); + this._updateRoomDetails(); })); if (!this._gridViewModel) { this._updateRoom(currentRoomId.get()); @@ -82,8 +82,8 @@ export class SessionViewModel extends ViewModel { this._updateLightbox(lightbox.get()); const details = this.navigation.observe("details"); - this.track(details.subscribe(() => this._toggleRoomDetailsPanel())); - this._toggleRoomDetailsPanel(); + this.track(details.subscribe(() => this._updateRoomDetails())); + this._updateRoomDetails(); } get id() { @@ -256,7 +256,7 @@ export class SessionViewModel extends ViewModel { return room; } - _toggleRoomDetailsPanel() { + _updateRoomDetails() { this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel); const enable = !!this.navigation.path.get("details")?.value; if (enable) { From 7b811aa92728a30a24e35d2e06b4c457d34203c3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 14 Jun 2021 20:33:32 +0530 Subject: [PATCH 127/213] Remove space after brace in two places Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 2 +- src/platform/web/ui/session/SessionView.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 12f80f8b..14bb087e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -261,7 +261,7 @@ export class SessionViewModel extends ViewModel { const enable = !!this.navigation.path.get("details")?.value; if (enable) { const room = this._roomFromNavigation(); - this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({ room }))); + this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room}))); } this.emitChange("roomDetailsViewModel"); } diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 31d5befa..877cc67c 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -25,7 +25,7 @@ import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; import {SettingsView} from "./settings/SettingsView.js"; -import { RoomDetailsView } from "./rightpanel/RoomDetailsView.js"; +import {RoomDetailsView} from "./rightpanel/RoomDetailsView.js"; export class SessionView extends TemplateView { render(t, vm) { From 88a1e34987286a560a14c21fdb08728746324e30 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 14 Jun 2021 21:04:44 +0530 Subject: [PATCH 128/213] Unsubscribe on dispose Signed-off-by: RMidhunSuresh --- .../session/rightpanel/RoomDetailsViewModel.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/domain/session/rightpanel/RoomDetailsViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js index 6430b922..911e5945 100644 --- a/src/domain/session/rightpanel/RoomDetailsViewModel.js +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -5,7 +5,8 @@ export class RoomDetailsViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; - this._room.on("change", () => this.emitChange()); + this._onRoomChange = this._onRoomChange.bind(this); + this._room.on("change", this._onRoomChange); } get roomId() { @@ -44,8 +45,17 @@ export class RoomDetailsViewModel extends ViewModel { return this.name; } + _onRoomChange() { + this.emitChange(); + } + closePanel() { const path = this.navigation.path.until("room"); this.navigation.applyPath(path); } + + dispose() { + super.dispose(); + this._room.off("change", this._onRoomChange); + } } From e50b503897d05a0054165a2d4b1dd953b5f3995b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 14 Jun 2021 22:43:42 +0530 Subject: [PATCH 129/213] Undo lint config change Signed-off-by: RMidhunSuresh --- .eslintrc.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 90c0f3fd..ebc08582 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,6 @@ module.exports = { "no-console": "off", "no-empty": "off", "no-prototype-builtins": "off", - "no-unused-vars": "warn", - "object-curly-spacing": ["warn", "never"] + "no-unused-vars": "warn" } }; From 97e484b8e6b739b1ea70922b32ee6202634edba0 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 15 Jun 2021 14:30:27 +0530 Subject: [PATCH 130/213] Rename toggle --> open Signed-off-by: RMidhunSuresh --- src/domain/session/room/RoomViewModel.js | 2 +- src/platform/web/ui/session/room/RoomView.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index ef103255..4d53ec6c 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -288,7 +288,7 @@ export class RoomViewModel extends ViewModel { return this._composerVM; } - toggleDetailsPanel() { + openDetailsPanel() { let path = this.navigation.path.until("room"); path = path.with(this.navigation.segment("details", true)); this.navigation.applyPath(path); diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 13259a08..f8e84f87 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,7 +68,7 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; - options.push(Menu.option(vm.i18n`Room details`, () => vm.toggleDetailsPanel())) + options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) if (vm.canLeave) { options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } From 4f05d9a5b76063767ed82a5dec83fee745eb21df Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 15 Jun 2021 14:34:26 +0530 Subject: [PATCH 131/213] Make navigation changes in one go Signed-off-by: RMidhunSuresh --- src/domain/session/RoomGridViewModel.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index c2608680..6dbc5ea4 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -78,17 +78,23 @@ export class RoomGridViewModel extends ViewModel { return this._height; } + _switchToRoom(roomId) { + const detailsShown = !!this.navigation.path.get("details")?.value; + let path = this.navigation.path.until("rooms"); + path = path.with(new Segment("room", roomId)); + if (detailsShown) { + path = path.with(new Segment("details", true)); + } + this.navigation.applyPath(path); + } + focusTile(index) { if (index === this._selectedIndex) { return; } const vmo = this._viewModelsObservables[index]; if (vmo) { - const detailsShown = !!this.navigation.path.get("details")?.value; - this.navigation.push("room", vmo.id); - if (detailsShown) { - this.navigation.push("details", true); - } + this._switchToRoom(vmo.id); } else { this.navigation.push("empty-grid-tile", index); } @@ -183,6 +189,7 @@ export class RoomGridViewModel extends ViewModel { import {createNavigation} from "../navigation/index.js"; import {ObservableValue} from "../../observable/ObservableValue.js"; +import { Segment } from "../navigation/Navigation.js"; export function tests() { class RoomVMMock { From 1772fc04f922d9c10bc31d914a3b5e3bb3f54beb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 15 Jun 2021 14:45:46 +0530 Subject: [PATCH 132/213] Remove text(..) Signed-off-by: RMidhunSuresh --- src/platform/web/ui/session/rightpanel/RoomDetailsView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 3b8f0077..a6d1a81f 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -1,5 +1,5 @@ import {TemplateView} from "../../general/TemplateView.js"; -import {text, classNames, tag} from "../../general/html.js"; +import {classNames, tag} from "../../general/html.js"; import {AvatarView} from "../../avatar.js"; export class RoomDetailsView extends TemplateView { @@ -23,14 +23,14 @@ export class RoomDetailsView extends TemplateView { } _createRoomAliasDisplay(vm) { - return vm.canonicalAlias ? tag.div({className: "RoomDetailsView_id"}, [text(vm.canonicalAlias)]) : + return vm.canonicalAlias ? tag.div({className: "RoomDetailsView_id"}, [vm.canonicalAlias]) : ""; } _createRightPanelRow(t, label, labelClass, value) { const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass}); return t.div({className: "RoomDetailsView_row"}, [ - t.div({className: labelClassString}, [text(label)]), + t.div({className: labelClassString}, [label]), t.div({className: "RoomDetailsView_value"}, value) ]); } From 5e4db2f5dc850f92d894fa4b520d2875935913e2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 15 Jun 2021 14:52:07 +0530 Subject: [PATCH 133/213] Change font size Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index c22ab79b..098833f6 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -672,7 +672,7 @@ button.link { background-color: transparent; text-align: left; padding: 8px 32px 8px 8px; - font-size: 1.6rem; + font-size: 1.5rem; height: 24px; cursor: pointer; } From e161f6131980920845feb4822f82fb94a8bb08bf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 15 Jun 2021 15:11:32 +0530 Subject: [PATCH 134/213] Remove selector list Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 413d53ec..f2474917 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -72,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { - .SessionView:not(.middle-shown, .right-shown) { + .SessionView:not(.middle-shown) { grid-template: "status" auto "left" 1fr / From e5c10941538b02a9e48f5a0998ea966a242f5575 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 15 Jun 2021 19:06:41 +0200 Subject: [PATCH 135/213] WIP --- .../room/timeline/ReactionsViewModel.js | 13 +++---- src/matrix/room/timeline/Timeline.js | 36 +++++++++++++------ .../timeline/entries/PendingEventEntry.js | 15 +++----- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 6364fc8b..dea74ccf 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -22,6 +22,7 @@ export class ReactionsViewModel { this._reactions = this._map.sortValues((a, b) => a._compare(b)); } + /** @package */ update(annotations, pendingAnnotations) { if (annotations) { for (const key in annotations) { @@ -33,7 +34,7 @@ export class ReactionsViewModel { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, annotation, 0, this._parentEntry)); } } } @@ -60,7 +61,7 @@ export class ReactionsViewModel { this._map.update(existingKey); } } else if (!hasPending) { - if (this._map.get(existingKey)._tryUpdatePending(null)) { + if (this._map.get(existingKey)._tryUpdatePending(0)) { this._map.update(existingKey); } } @@ -109,18 +110,15 @@ class ReactionViewModel { } get count() { - let count = 0; + let count = this._pendingCount; if (this._annotation) { count += this._annotation.count; } - if (this._pendingCount !== null) { - count += this._pendingCount; - } return count; } get isPending() { - return this._pendingCount !== null; + return this._pendingCount !== 0; } get haveReacted() { @@ -156,7 +154,6 @@ class ReactionViewModel { async toggleReaction() { if (this._isToggling) { - console.log("blocking toggleReaction, call ongoing"); return; } this._isToggling = true; diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 35593663..c50523f3 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; -import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; +import {getRelation, getRelationFromContent, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {REDACTION_TYPE} from "../common.js"; export class Timeline { @@ -116,19 +116,34 @@ export class Timeline { } async _mapPendingEventToEntry(pe) { - // we load the remote redaction target for pending events, + // we load the redaction target for pending events, // so if we are redacting a relation, we can pass the redaction // to the relation target and the removal of the relation can // be taken into account for local echo. - let redactionTarget; - if (pe.eventType === REDACTION_TYPE && pe.relatedEventId) { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.timelineEvents, - ]); - const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, pe.relatedEventId); - redactionTarget = redactionTargetEntry?.event; + let redactingRelation; + if (pe.eventType === REDACTION_TYPE) { + if (pe.relatedEventId) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineEvents, + ]); + const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, pe.relatedEventId); + if (redactionTargetEntry) { + redactingRelation = getRelation(redactionTargetEntry.event); + } + } else if (pe.relatedTxnId) { + // also look for redacting relation in pending events, in case the target is already being sent + for (const p of this._localEntries) { + if (p.id === pe.relatedTxnId) { + redactingRelation = getRelationFromContent(p.content); + break; + } + } + } } - const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock, redactionTarget}); + const pee = new PendingEventEntry({ + pendingEvent: pe, member: this._ownMember, + clock: this._clock, redactingRelation + }); this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee)); return pee; } @@ -158,6 +173,7 @@ export class Timeline { // also look for a relation target to update with this redaction if (pee.redactingRelation) { const eventId = pee.redactingRelation.event_id; + // TODO: also support reacting to pending entries this._remoteEntries.findAndUpdate( e => e.id === eventId, updateOrFalse diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index d9aa4b80..edb728cd 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -19,13 +19,13 @@ import {BaseEventEntry} from "./BaseEventEntry.js"; import {getRelationFromContent} from "../relations.js"; export class PendingEventEntry extends BaseEventEntry { - constructor({pendingEvent, member, clock, redactionTarget}) { + constructor({pendingEvent, member, clock, redactingRelation}) { super(null); this._pendingEvent = pendingEvent; /** @type {RoomMember} */ this._member = member; this._clock = clock; - this._redactionTarget = redactionTarget; + this._redactingRelation = redactingRelation; } get fragmentId() { @@ -89,10 +89,7 @@ export class PendingEventEntry extends BaseEventEntry { } get redactingRelation() { - if (this._redactionTarget) { - return getRelationFromContent(this._redactionTarget.content); - } - return null; + return this._redactingRelation; } /** * returns either the relationship on this entry, @@ -100,10 +97,6 @@ export class PendingEventEntry extends BaseEventEntry { * * Useful while aggregating relations for local echo. */ get ownOrRedactedRelation() { - if (this._redactionTarget) { - return getRelationFromContent(this._redactionTarget.content); - } else { - return getRelationFromContent(this._pendingEvent.content); - } + return this.redactingRelation || getRelationFromContent(this._pendingEvent.content); } } From 3b629622d9a3d76a9eef76eaed41592059d57d58 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 10:23:22 +0200 Subject: [PATCH 136/213] need to keep pending count around if 0 or less for redaction local echo also need to be able to tell the difference between no pending reactions and redactions and the sum being 0 (having both a redaction and reaction) so we keep isPending to true --- .../session/room/timeline/ReactionsViewModel.js | 14 ++++++++------ src/matrix/room/timeline/PendingAnnotations.js | 9 ++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index dea74ccf..5df57089 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -34,7 +34,7 @@ export class ReactionsViewModel { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, annotation, 0, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentEntry)); } } } @@ -61,7 +61,7 @@ export class ReactionsViewModel { this._map.update(existingKey); } } else if (!hasPending) { - if (this._map.get(existingKey)._tryUpdatePending(0)) { + if (this._map.get(existingKey)._tryUpdatePending(null)) { this._map.update(existingKey); } } @@ -110,7 +110,7 @@ class ReactionViewModel { } get count() { - let count = this._pendingCount; + let count = this._pendingCount || 0; if (this._annotation) { count += this._annotation.count; } @@ -118,7 +118,9 @@ class ReactionViewModel { } get isPending() { - return this._pendingCount !== 0; + // even if pendingCount is 0, + // it means we have both a pending reaction and redaction + return this._pendingCount !== null; } get haveReacted() { @@ -158,8 +160,8 @@ class ReactionViewModel { } this._isToggling = true; try { - const haveLocalRedaction = this._pendingCount < 0; - const havePendingReaction = this._pendingCount > 0; + const haveLocalRedaction = this.isPending && this._pendingCount < 0; + const havePendingReaction = this.isPending && this._pendingCount > 0; const haveRemoteReaction = this._annotation?.me; const haveReaction = havePendingReaction || (haveRemoteReaction && !haveLocalRedaction); if (haveReaction) { diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js index 38034a00..05495bc3 100644 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -47,11 +47,10 @@ export class PendingAnnotations { if (count !== undefined) { const addend = entry.isRedaction ? 1 : -1; count += addend; - if (count <= 0) { - this.aggregatedAnnotations.delete(key); - } else { - this.aggregatedAnnotations.set(key, count); - } + this.aggregatedAnnotations.set(key, count); + } + if (!this._entries.length) { + this.aggregatedAnnotations.clear(); } } From 4f10174e48ff3f23c590657c749d778dace6dd35 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 10:28:17 +0200 Subject: [PATCH 137/213] clarify comment --- src/matrix/room/timeline/Timeline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index c50523f3..c73fe9db 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -163,7 +163,7 @@ export class Timeline { updateOrFalse, ); } - // now look in remote entries based on event id + // if not found here, look in remote entries based on event id if (!found && pee.relatedEventId) { this._remoteEntries.findAndUpdate( e => e.id === pee.relatedEventId, From 94635a18e06475136bdac976e25c66a38249bac9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 12:41:42 +0200 Subject: [PATCH 138/213] actually, 0 or -1 mean you have a local redaction --- src/domain/session/room/timeline/ReactionsViewModel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 5df57089..575c912b 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -160,7 +160,8 @@ class ReactionViewModel { } this._isToggling = true; try { - const haveLocalRedaction = this.isPending && this._pendingCount < 0; + // TODO: should some of this go into BaseMessageTile? + const haveLocalRedaction = this.isPending && this._pendingCount <= 0; const havePendingReaction = this.isPending && this._pendingCount > 0; const haveRemoteReaction = this._annotation?.me; const haveReaction = havePendingReaction || (haveRemoteReaction && !haveLocalRedaction); From bbcf0d2572332f9e21ef778ebed231fa39cf0c98 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 12:46:44 +0200 Subject: [PATCH 139/213] more local echo fixes for redacting a reaction + cleanup --- .../room/timeline/PendingAnnotations.js | 12 +-- src/matrix/room/timeline/Timeline.js | 81 +++++++++++-------- .../room/timeline/entries/BaseEventEntry.js | 24 ++++-- .../timeline/entries/PendingEventEntry.js | 24 +++--- 4 files changed, 78 insertions(+), 63 deletions(-) diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js index 05495bc3..1dd32abd 100644 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ b/src/matrix/room/timeline/PendingAnnotations.js @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getRelationFromContent} from "./relations.js"; - export class PendingAnnotations { constructor() { this.aggregatedAnnotations = new Map(); @@ -25,7 +23,7 @@ export class PendingAnnotations { /** adds either a pending annotation entry, or a remote annotation entry with a pending redaction */ add(entry) { - const {key} = entry.ownOrRedactedRelation; + const {key} = (entry.redactingEntry || entry).relation; if (!key) { return; } @@ -42,7 +40,7 @@ export class PendingAnnotations { return; } this._entries.splice(idx, 1); - const {key} = entry.ownOrRedactedRelation; + const {key} = (entry.redactingEntry || entry).relation; let count = this.aggregatedAnnotations.get(key); if (count !== undefined) { const addend = entry.isRedaction ? 1 : -1; @@ -56,8 +54,7 @@ export class PendingAnnotations { findForKey(key) { return this._entries.find(e => { - const relation = getRelationFromContent(e.content); - if (relation && relation.key === key) { + if (e.relation?.key === key) { return e; } }); @@ -65,8 +62,7 @@ export class PendingAnnotations { findRedactionForKey(key) { return this._entries.find(e => { - const relation = e.redactingRelation; - if (relation && relation.key === key) { + if (e.redactingEntry?.relation?.key === key) { return e; } }); diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index c73fe9db..76b6bf5f 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {PowerLevels} from "./PowerLevels.js"; -import {getRelation, getRelationFromContent, ANNOTATION_RELATION_TYPE} from "./relations.js"; +import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {REDACTION_TYPE} from "../common.js"; export class Timeline { @@ -120,42 +120,36 @@ export class Timeline { // so if we are redacting a relation, we can pass the redaction // to the relation target and the removal of the relation can // be taken into account for local echo. - let redactingRelation; + let redactingEntry; if (pe.eventType === REDACTION_TYPE) { - if (pe.relatedEventId) { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.timelineEvents, - ]); - const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, pe.relatedEventId); - if (redactionTargetEntry) { - redactingRelation = getRelation(redactionTargetEntry.event); - } - } else if (pe.relatedTxnId) { - // also look for redacting relation in pending events, in case the target is already being sent - for (const p of this._localEntries) { - if (p.id === pe.relatedTxnId) { - redactingRelation = getRelationFromContent(p.content); - break; - } - } - } + redactingEntry = await this._getOrLoadEntry(pe.relatedTxnId, pe.relatedEventId); } const pee = new PendingEventEntry({ pendingEvent: pe, member: this._ownMember, - clock: this._clock, redactingRelation + clock: this._clock, redactingEntry }); this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee)); return pee; } - _applyAndEmitLocalRelationChange(pee, updater) { + // this is the contract of findAndUpdate, used in _findAndUpdateRelatedEntry const updateOrFalse = e => { const params = updater(e); return params ? params : false; }; + this._findAndUpdateRelatedEntry(pee.pendingEvent.relatedTxnId, pee.relatedEventId, updateOrFalse); + // also look for a relation target to update with this redaction + const {redactingEntry} = pee; + if (redactingEntry) { + // redactingEntry might be a PendingEventEntry or an EventEntry, so don't assume pendingEvent + const relatedTxnId = redactingEntry.pendingEvent?.relatedTxnId; + this._findAndUpdateRelatedEntry(relatedTxnId, redactingEntry.relatedEventId, updateOrFalse); + } + } + + _findAndUpdateRelatedEntry(relatedTxnId, relatedEventId, updateOrFalse) { let found = false; - const {relatedTxnId} = pee.pendingEvent; // first, look in local entries based on txn id if (relatedTxnId) { found = this._localEntries.findAndUpdate( @@ -164,18 +158,9 @@ export class Timeline { ); } // if not found here, look in remote entries based on event id - if (!found && pee.relatedEventId) { + if (!found && relatedEventId) { this._remoteEntries.findAndUpdate( - e => e.id === pee.relatedEventId, - updateOrFalse - ); - } - // also look for a relation target to update with this redaction - if (pee.redactingRelation) { - const eventId = pee.redactingRelation.event_id; - // TODO: also support reacting to pending entries - this._remoteEntries.findAndUpdate( - e => e.id === eventId, + e => e.id === relatedEventId, updateOrFalse ); } @@ -233,8 +218,8 @@ export class Timeline { relationTarget.addLocalRelation(pee); } } - if (pee.redactingRelation) { - const eventId = pee.redactingRelation.event_id; + if (pee.redactingEntry) { + const eventId = pee.redactingEntry.relatedEventId; const relationTarget = entries.find(e => e.id === eventId); if (relationTarget) { relationTarget.addLocalRelation(pee); @@ -278,6 +263,32 @@ export class Timeline { } } + async _getOrLoadEntry(txnId, eventId) { + if (txnId) { + // also look for redacting relation in pending events, in case the target is already being sent + for (const p of this._localEntries) { + if (p.id === txnId) { + return p; + } + } + } + if (eventId) { + const loadedEntry = this.getByEventId(eventId); + if (loadedEntry) { + return loadedEntry; + } else { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.timelineEvents, + ]); + const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); + if (redactionTargetEntry) { + return new EventEntry(redactionTargetEntry, this._fragmentIdComparer); + } + } + } + return null; + } + getByEventId(eventId) { for (let i = 0; i < this._remoteEntries.length; i += 1) { const entry = this._remoteEntries.get(i); diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 6c37457a..3ac98c8c 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,9 +16,11 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; -import {createAnnotation, ANNOTATION_RELATION_TYPE} from "../relations.js"; +import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotations} from "../PendingAnnotations.js"; +/** Deals mainly with local echo for relations and redactions, + * so it is shared between PendingEventEntry and EventEntry */ export class BaseEventEntry extends BaseEntry { constructor(fragmentIdComparer) { super(fragmentIdComparer); @@ -59,9 +61,9 @@ export class BaseEventEntry extends BaseEntry { return "isRedacted"; } } else { - const relation = entry.ownOrRedactedRelation; - if (relation && relation.event_id === this.id) { - if (relation.rel_type === ANNOTATION_RELATION_TYPE) { + const relationEntry = entry.redactingEntry || entry; + if (relationEntry.isRelationForId(this.id)) { + if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) { if (!this._pendingAnnotations) { this._pendingAnnotations = new PendingAnnotations(); } @@ -87,9 +89,9 @@ export class BaseEventEntry extends BaseEntry { } } } else { - const relation = entry.ownOrRedactedRelation; - if (relation && relation.event_id === this.id) { - if (relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { + const relationEntry = entry.redactingEntry || entry; + if (relationEntry.isRelationForId(this.id)) { + if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { this._pendingAnnotations.remove(entry); if (this._pendingAnnotations.isEmpty) { this._pendingAnnotations = null; @@ -121,6 +123,14 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } + isRelationForId(id) { + return id && this.relation?.event_id === id; + } + + get relation() { + return getRelationFromContent(this.content); + } + get pendingAnnotations() { return this._pendingAnnotations?.aggregatedAnnotations; } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index edb728cd..7f5a87af 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -16,16 +16,15 @@ limitations under the License. import {PENDING_FRAGMENT_ID} from "./BaseEntry.js"; import {BaseEventEntry} from "./BaseEventEntry.js"; -import {getRelationFromContent} from "../relations.js"; export class PendingEventEntry extends BaseEventEntry { - constructor({pendingEvent, member, clock, redactingRelation}) { + constructor({pendingEvent, member, clock, redactingEntry}) { super(null); this._pendingEvent = pendingEvent; /** @type {RoomMember} */ this._member = member; this._clock = clock; - this._redactingRelation = redactingRelation; + this._redactingEntry = redactingEntry; } get fragmentId() { @@ -84,19 +83,18 @@ export class PendingEventEntry extends BaseEventEntry { } + isRelationForId(id) { + if (id && id === this._pendingEvent.relatedTxnId) { + return true; + } + return super.isRelationForId(id); + } + get relatedEventId() { return this._pendingEvent.relatedEventId; } - get redactingRelation() { - return this._redactingRelation; - } - /** - * returns either the relationship on this entry, - * or the relationship this entry is redacting. - * - * Useful while aggregating relations for local echo. */ - get ownOrRedactedRelation() { - return this.redactingRelation || getRelationFromContent(this._pendingEvent.content); + get redactingEntry() { + return this._redactingEntry; } } From 9099a76f45bae8ad1cc6d894144bad48279fa8c8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 17:30:48 +0200 Subject: [PATCH 140/213] fix spelling in comment --- src/matrix/room/sending/PendingEvent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 9f54e3c3..7738847f 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -53,7 +53,7 @@ export class PendingEvent { get relatedEventId() { const relation = getRelationFromContent(this.content); if (relation) { - // may be null when target is not sent yet, is indented + // may be null when target is not sent yet, is intended return relation.event_id; } else { return this._data.relatedEventId; From ce5409dc26203d5992f5d103610bb3eb55ec07dc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 17:40:29 +0200 Subject: [PATCH 141/213] aggregate relations when seeing event target during back-pagination --- .../room/timeline/persistence/GapWriter.js | 9 +++--- .../timeline/persistence/RelationWriter.js | 30 +++++++++++++++++-- .../idb/stores/TimelineRelationStore.js | 13 ++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 7b6e7600..2fc56a21 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -119,13 +119,14 @@ export class GapWriter { eventStorageEntry.displayName = member.displayName; eventStorageEntry.avatarUrl = member.avatarUrl; } - txn.timelineEvents.insert(eventStorageEntry); - const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); - directionalAppend(entries, eventEntry, direction); - const updatedRelationTargetEntries = await this._relationWriter.writeRelation(eventEntry, txn, log); + // this will modify eventStorageEntry if it is a relation target + const updatedRelationTargetEntries = await this._relationWriter.writeGapRelation(eventStorageEntry, direction, txn, log); if (updatedRelationTargetEntries) { updatedEntries.push(...updatedRelationTargetEntries); } + txn.timelineEvents.insert(eventStorageEntry); + const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); + directionalAppend(entries, eventEntry, direction); } return {entries, updatedEntries}; } diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 3838c494..13b0014d 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -44,10 +44,36 @@ export class RelationWriter { } } } - // TODO: check if sourceEntry is in timelineRelations as a target, and if so reaggregate it return null; } + /** + * @param {Object} storageEntry the event object, as it will be stored in storage. + * Will be modified (but not written to storage) in case this event is + * a relation target for which we've previously received relations. + * @param {Direction} direction of the gap fill + * */ + async writeGapRelation(storageEntry, direction, txn, log) { + const sourceEntry = new EventEntry(storageEntry, this._fragmentIdComparer); + const result = await this.writeRelation(sourceEntry, txn, log); + // when back-paginating, it can also happen that we've received relations + // for this event before, which now upon receiving the target need to be aggregated. + if (direction.isBackward) { + const relations = await txn.timelineRelations.getAllForTarget(this._roomId, sourceEntry.id); + if (relations.length) { + for (const r of relations) { + const relationStorageEntry = await txn.timelineEvents.getByEventId(this._roomId, r.sourceEventId); + if (relationStorageEntry) { + const relationEntry = new EventEntry(relationStorageEntry, this._fragmentIdComparer); + await this._applyRelation(relationEntry, storageEntry, txn, log); + } + } + } + } + + return result; + } + /** * @param {EventEntry} sourceEntry * @param {Object} targetStorageEntry event entry as stored in the timelineEvents store @@ -224,4 +250,4 @@ const _REDACT_KEEP_CONTENT_MAP = { }, 'm.room.aliases': {'aliases': 1}, }; -// end of matrix-js-sdk code \ No newline at end of file +// end of matrix-js-sdk code diff --git a/src/matrix/storage/idb/stores/TimelineRelationStore.js b/src/matrix/storage/idb/stores/TimelineRelationStore.js index 504693f9..013fb2d6 100644 --- a/src/matrix/storage/idb/stores/TimelineRelationStore.js +++ b/src/matrix/storage/idb/stores/TimelineRelationStore.js @@ -59,4 +59,17 @@ export class TimelineRelationStore { const keys = await this._store.selectAll(range); return keys.map(decodeKey); } + + async getAllForTarget(roomId, targetId) { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = this._store.IDBKeyRange.bound( + encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE), + encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE), + true, + true + ); + const keys = await this._store.selectAll(range); + return keys.map(decodeKey); + } } From 150f58a6b3134c1562e0f13c3de2a89dbed62559 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 16 Jun 2021 18:00:50 +0200 Subject: [PATCH 142/213] don't aggregate relations on redacted events --- src/matrix/room/common.js | 4 ++++ src/matrix/room/timeline/entries/EventEntry.js | 4 ++-- src/matrix/room/timeline/persistence/RelationWriter.js | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/common.js b/src/matrix/room/common.js index 721160e6..b009a89c 100644 --- a/src/matrix/room/common.js +++ b/src/matrix/room/common.js @@ -21,3 +21,7 @@ export function getPrevContentFromStateEvent(event) { } export const REDACTION_TYPE = "m.room.redaction"; + +export function isRedacted(event) { + return !!event?.unsigned?.redacted_because; +} diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 2aa9cba0..3a6889f7 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseEventEntry} from "./BaseEventEntry.js"; -import {getPrevContentFromStateEvent} from "../../common.js"; +import {getPrevContentFromStateEvent, isRedacted} from "../../common.js"; import {getRelatedEventId} from "../relations.js"; export class EventEntry extends BaseEventEntry { @@ -115,7 +115,7 @@ export class EventEntry extends BaseEventEntry { } get isRedacted() { - return super.isRedacted || !!this._eventEntry.event.unsigned?.redacted_because; + return super.isRedacted || isRedacted(this._eventEntry.event); } get redactionReason() { diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 13b0014d..853dcb0b 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -15,7 +15,7 @@ limitations under the License. */ import {EventEntry} from "../entries/EventEntry.js"; -import {REDACTION_TYPE} from "../../common.js"; +import {REDACTION_TYPE, isRedacted} from "../../common.js"; import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js"; export class RelationWriter { @@ -58,7 +58,7 @@ export class RelationWriter { const result = await this.writeRelation(sourceEntry, txn, log); // when back-paginating, it can also happen that we've received relations // for this event before, which now upon receiving the target need to be aggregated. - if (direction.isBackward) { + if (direction.isBackward && !isRedacted(storageEntry.event)) { const relations = await txn.timelineRelations.getAllForTarget(this._roomId, sourceEntry.id); if (relations.length) { for (const r of relations) { @@ -99,7 +99,7 @@ export class RelationWriter { }); } else { const relation = getRelation(sourceEntry.event); - if (relation) { + if (relation && !isRedacted(targetStorageEntry.event)) { const relType = relation.rel_type; if (relType === ANNOTATION_RELATION_TYPE) { const aggregated = log.wrap("react", log => { From fd54539e1c46b4362331f486750a5626681e3898 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 09:41:10 +0200 Subject: [PATCH 143/213] clarify comment --- src/domain/session/room/timeline/ReactionsViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 575c912b..8bf04d4f 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -128,7 +128,7 @@ class ReactionViewModel { } _compare(other) { - // the comparator is also used to test for equality, if the comparison returns 0 + // the comparator is also used to test for equality by sortValues, if the comparison returns 0 // given that the firstTimestamp isn't set anymore when the last reaction is removed, // the remove event wouldn't be able to find the correct index anymore. So special case equality. if (other === this) { From 099f99a96b8ac3684abbf8e34f2ff41a703accda Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 09:41:25 +0200 Subject: [PATCH 144/213] check power levels to see if we can react --- .../room/timeline/tiles/BaseMessageTile.js | 3 +- src/matrix/room/timeline/PowerLevels.js | 61 +++++++++++++++++-- .../session/room/timeline/BaseMessageView.js | 5 +- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 3c20f7f7..b73e1c26 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -119,8 +119,7 @@ export class BaseMessageTile extends SimpleTile { } get canReact() { - // TODO - return true; + return this._powerLevels.canSendType("m.reaction"); } react(key, log = null) { diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index 87be562f..9161c8ed 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -29,8 +29,16 @@ export class PowerLevels { } } + canSendType(eventType) { + return this._myLevel >= this._getEventTypeLevel(eventType); + } + get canRedact() { - return this._getUserLevel(this._ownUserId) >= this._getActionLevel("redact"); + return this._myLevel >= this._getActionLevel("redact"); + } + + get _myLevel() { + return this._getUserLevel(this._ownUserId); } _getUserLevel(userId) { @@ -59,25 +67,51 @@ export class PowerLevels { return 50; } } + + _getEventTypeLevel(eventType) { + const level = this._plEvent?.content.events?.[eventType]; + if (typeof level === "number") { + return level; + } else { + const level = this._plEvent?.content.events_default; + if (typeof level === "number") { + return level; + } else { + return 0; + } + } + } } export function tests() { const alice = "@alice:hs.tld"; const bob = "@bob:hs.tld"; + const charly = "@charly:hs.tld"; const createEvent = {content: {creator: alice}}; - const powerLevelEvent = {content: { + const redactPowerLevelEvent = {content: { redact: 50, users: { [alice]: 50 }, users_default: 0 }}; + const eventsPowerLevelEvent = {content: { + events_default: 5, + events: { + "m.room.message": 45 + }, + users: { + [alice]: 50, + [bob]: 10 + }, + users_default: 0 + }}; return { "redact somebody else event with power level event": assert => { - const pl1 = new PowerLevels({powerLevelEvent, ownUserId: alice}); + const pl1 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: alice}); assert.equal(pl1.canRedact, true); - const pl2 = new PowerLevels({powerLevelEvent, ownUserId: bob}); + const pl2 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: bob}); assert.equal(pl2.canRedact, false); }, "redact somebody else event with create event": assert => { @@ -91,5 +125,22 @@ export function tests() { assert.equal(pl.canRedactFromSender(alice), true); assert.equal(pl.canRedactFromSender(bob), false); }, + "can send event without power levels": assert => { + const pl = new PowerLevels({createEvent, ownUserId: charly}); + assert.equal(pl.canSendType("m.room.message"), true); + }, + "can't send any event below events_default": assert => { + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: charly}); + assert.equal(pl.canSendType("m.foo"), false); + }, + "can't send event below events[type]": assert => { + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: bob}); + assert.equal(pl.canSendType("m.foo"), true); + assert.equal(pl.canSendType("m.room.message"), false); + }, + "can send event below events[type]": assert => { + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice}); + assert.equal(pl.canSendType("m.room.message"), true); + }, } -} \ No newline at end of file +} diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 9afd7609..8c98a3a2 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -110,12 +110,15 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; + if (vm.canReact) { + options.push(Menu.option(vm.i18n`React with 👍`, () => vm.react("👍"))) + options.push(Menu.option(vm.i18n`React with 🙈`, () => vm.react("🙈"))) + } if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } - options.push(Menu.option(vm.i18n`React with 👍`, () => vm.react("👍"))) return options; } From bf84b59e39d66ddfadc84803a5c956bf4f1f6efe Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 09:59:24 +0200 Subject: [PATCH 145/213] more accurate test name and also test >= --- src/matrix/room/timeline/PowerLevels.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index 9161c8ed..f2315c38 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -98,7 +98,8 @@ export function tests() { const eventsPowerLevelEvent = {content: { events_default: 5, events: { - "m.room.message": 45 + "m.room.message": 45, + "m.room.topic": 50, }, users: { [alice]: 50, @@ -138,9 +139,10 @@ export function tests() { assert.equal(pl.canSendType("m.foo"), true); assert.equal(pl.canSendType("m.room.message"), false); }, - "can send event below events[type]": assert => { + "can send event above or at events[type]": assert => { const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice}); assert.equal(pl.canSendType("m.room.message"), true); + assert.equal(pl.canSendType("m.room.topic"), true); }, } } From cbee498d41008fd39939c2f70d8215b3eaae74d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 10:03:32 +0200 Subject: [PATCH 146/213] a bit more brief --- src/matrix/room/timeline/Timeline.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 76b6bf5f..00bdb1e1 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -140,11 +140,10 @@ export class Timeline { }; this._findAndUpdateRelatedEntry(pee.pendingEvent.relatedTxnId, pee.relatedEventId, updateOrFalse); // also look for a relation target to update with this redaction - const {redactingEntry} = pee; - if (redactingEntry) { + if (pee.redactingEntry) { // redactingEntry might be a PendingEventEntry or an EventEntry, so don't assume pendingEvent - const relatedTxnId = redactingEntry.pendingEvent?.relatedTxnId; - this._findAndUpdateRelatedEntry(relatedTxnId, redactingEntry.relatedEventId, updateOrFalse); + const relatedTxnId = pee.redactingEntry.pendingEvent?.relatedTxnId; + this._findAndUpdateRelatedEntry(relatedTxnId, pee.redactingEntry.relatedEventId, updateOrFalse); } } From a77ef0267711b68a163107cddffbe7079f025bb5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 10:12:45 +0200 Subject: [PATCH 147/213] cleanup --- src/matrix/room/timeline/Timeline.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 00bdb1e1..0ce51920 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -212,17 +212,13 @@ export class Timeline { // this will work because we set relatedEventId when removing remote echos if (pee.relatedEventId) { const relationTarget = entries.find(e => e.id === pee.relatedEventId); - if (relationTarget) { - // no need to emit here as this entry is about to be added - relationTarget.addLocalRelation(pee); - } + // no need to emit here as this entry is about to be added + relationTarget?.addLocalRelation(pee); } if (pee.redactingEntry) { const eventId = pee.redactingEntry.relatedEventId; const relationTarget = entries.find(e => e.id === eventId); - if (relationTarget) { - relationTarget.addLocalRelation(pee); - } + relationTarget?.addLocalRelation(pee); } } } From cad884aa412dbfa61bbbf47b3201a1e53c81bdac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 16:07:09 +0200 Subject: [PATCH 148/213] fix local redaction echo while already sending target --- .../room/timeline/entries/BaseEventEntry.js | 15 ++++++++------- .../room/timeline/entries/PendingEventEntry.js | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 3ac98c8c..45874dcb 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -52,7 +52,7 @@ export class BaseEventEntry extends BaseEntry { @return [string] returns the name of the field that has changed, if any */ addLocalRelation(entry) { - if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id) { + if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id)) { if (!this._pendingRedactions) { this._pendingRedactions = []; } @@ -62,7 +62,7 @@ export class BaseEventEntry extends BaseEntry { } } else { const relationEntry = entry.redactingEntry || entry; - if (relationEntry.isRelationForId(this.id)) { + if (relationEntry.isRelatedToId(this.id)) { if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) { if (!this._pendingAnnotations) { this._pendingAnnotations = new PendingAnnotations(); @@ -79,7 +79,7 @@ export class BaseEventEntry extends BaseEntry { @return [string] returns the name of the field that has changed, if any */ removeLocalRelation(entry) { - if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id && this._pendingRedactions) { + if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id) && this._pendingRedactions) { const countBefore = this._pendingRedactions.length; this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry); if (this._pendingRedactions.length === 0) { @@ -90,8 +90,8 @@ export class BaseEventEntry extends BaseEntry { } } else { const relationEntry = entry.redactingEntry || entry; - if (relationEntry.isRelationForId(this.id)) { - if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { + if (relationEntry.isRelatedToId(this.id)) { + if (relationEntry.relation?.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { this._pendingAnnotations.remove(entry); if (this._pendingAnnotations.isEmpty) { this._pendingAnnotations = null; @@ -123,8 +123,9 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } - isRelationForId(id) { - return id && this.relation?.event_id === id; + /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ + isRelatedToId(id) { + return id && this.relatedEventId === id; } get relation() { diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 7f5a87af..d42211ef 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -83,11 +83,11 @@ export class PendingEventEntry extends BaseEventEntry { } - isRelationForId(id) { + isRelatedToId(id) { if (id && id === this._pendingEvent.relatedTxnId) { return true; } - return super.isRelationForId(id); + return super.isRelatedToId(id); } get relatedEventId() { From 70d64f38eb60b2e1fa86fcab4fbf89d981f9479a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 16:07:32 +0200 Subject: [PATCH 149/213] spelling --- src/matrix/room/timeline/Timeline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 0ce51920..8274fc55 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -352,7 +352,7 @@ export function tests() { function() {}; return { - "adding or replacing entries before subscribing to entries does not loose local relations": async assert => { + "adding or replacing entries before subscribing to entries does not lose local relations": async assert => { const pendingEvents = new ObservableArray(); const timeline = new Timeline({ roomId, From 4312610e7d35a8ce3ccfa4ba8f7ae7e523c38e4c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 16:45:53 +0200 Subject: [PATCH 150/213] support menu options with custom DOM --- src/platform/web/ui/general/Menu.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index 2fed5e2d..be5dea1d 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -27,18 +27,7 @@ export class Menu extends TemplateView { } render(t) { - return t.ul({className: "menu", role: "menu"}, this._options.map(o => { - const className = { - destructive: o.destructive, - }; - if (o.icon) { - className.icon = true; - className[o.icon] = true; - } - return t.li({ - className, - }, t.button({onClick: o.callback}, o.label)); - })); + return t.ul({className: "menu", role: "menu"}, this._options.map(o => o.toDOM(t))); } } @@ -59,4 +48,17 @@ class MenuOption { this.destructive = true; return this; } + + toDOM(t) { + const className = { + destructive: this.destructive, + }; + if (this.icon) { + className.icon = true; + className[this.icon] = true; + } + return t.li({ + className, + }, t.button({onClick: this.callback}, this.label)); + } } From 64f1abdfedf3b2e6828ec5ce5d373f4d6096baea Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 16:46:06 +0200 Subject: [PATCH 151/213] show quick reactions in message menu --- .../web/ui/css/themes/element/theme.css | 10 +++++++++ .../session/room/timeline/BaseMessageView.js | 21 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index b0bf7854..348f1dcd 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -660,6 +660,16 @@ button.link { margin: 0; } +.menu .quick-reactions { + display: flex; + padding: 8px 32px 8px 8px; +} + +.menu .quick-reactions button { + padding: 2px 4px; + text-align: center; +} + .menu button { border-radius: 4px; display: block; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 8c98a3a2..c33eeae4 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -111,8 +111,7 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; if (vm.canReact) { - options.push(Menu.option(vm.i18n`React with 👍`, () => vm.react("👍"))) - options.push(Menu.option(vm.i18n`React with 🙈`, () => vm.react("🙈"))) + options.push(new QuickReactionsMenuOption(vm)); } if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); @@ -124,3 +123,21 @@ export class BaseMessageView extends TemplateView { renderMessageBody() {} } + +class QuickReactionsMenuOption { + constructor(vm) { + this._vm = vm; + } + toDOM(t) { + const emojiButtons = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => { + return t.button({onClick: () => this._vm.react(emoji)}, emoji); + }); + const customButton = t.button({onClick: () => { + const key = prompt("Enter your reaction (emoji)"); + if (key) { + this._vm.react(key); + } + }}, "…"); + return t.li({className: "quick-reactions"}, [...emojiButtons, customButton]); + } +} From f000e986194be7bc966f66188e35dcccee070133 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 17 Jun 2021 16:48:58 +0200 Subject: [PATCH 152/213] no point in reacting to redacted messages --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index c33eeae4..c5d860f2 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -110,7 +110,7 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; - if (vm.canReact) { + if (vm.canReact && vm.shape !== "redacted") { options.push(new QuickReactionsMenuOption(vm)); } if (vm.canAbortSending) { From 8092713faa226aa5eb869f923567dca10d68d749 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 11:51:02 +0200 Subject: [PATCH 153/213] add tests for local echo of adding and removing reaction --- src/matrix/room/timeline/Timeline.js | 96 ++++++++++++++++++++++++++-- src/mocks/event.js | 4 +- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 8274fc55..415dd0fc 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -173,7 +173,7 @@ export class Timeline { const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE); for (const relation of relations) { const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId); - if (annotation.event.sender === this._ownMember.userId && getRelation(annotation.event).key === key) { + if (annotation && annotation.event.sender === this._ownMember.userId && getRelation(annotation.event).key === key) { const eventEntry = new EventEntry(annotation, this._fragmentIdComparer); this._addLocalRelationsToNewRemoteEntries([eventEntry]); return eventEntry; @@ -334,15 +334,15 @@ import {FragmentIdComparer} from "./FragmentIdComparer.js"; import {poll} from "../../../mocks/poll.js"; import {Clock as MockClock} from "../../../mocks/Clock.js"; import {createMockStorage} from "../../../mocks/Storage.js"; -import {createEvent, withTextBody, withSender} from "../../../mocks/event.js"; +import {createEvent, withTextBody, withContent, withSender} from "../../../mocks/event.js"; import {NullLogItem} from "../../../logging/NullLogger.js"; import {EventEntry} from "./entries/EventEntry.js"; import {User} from "../../User.js"; import {PendingEvent} from "../sending/PendingEvent.js"; +import {createAnnotation} from "./relations.js"; export function tests() { const fragmentIdComparer = new FragmentIdComparer([]); - const roomId = "$abc"; const noopHandler = {}; noopHandler.onAdd = noopHandler.onUpdate = @@ -351,6 +351,21 @@ export function tests() { noopHandler.onReset = function() {}; + const roomId = "$abc"; + const alice = "@alice:hs.tld"; + const bob = "@bob:hs.tld"; + + function getIndexFromIterable(it, n) { + let i = 0; + for (const item of it) { + if (i === n) { + return item; + } + i += 1; + } + throw new Error("not enough items in iterable"); + } + return { "adding or replacing entries before subscribing to entries does not lose local relations": async assert => { const pendingEvents = new ObservableArray(); @@ -363,12 +378,12 @@ export function tests() { clock: new MockClock(), }); // 1. load timeline - await timeline.load(new User("@alice:hs.tld"), "join", new NullLogItem()); + await timeline.load(new User(alice), "join", new NullLogItem()); // 2. test replaceEntries and addOrReplaceEntries don't fail - const event1 = withTextBody("hi!", withSender("@bob:hs.tld", createEvent("m.room.message", "!abc"))); + const event1 = withTextBody("hi!", withSender(bob, createEvent("m.room.message", "!abc"))); const entry1 = new EventEntry({event: event1, fragmentId: 1, eventIndex: 1}, fragmentIdComparer); timeline.replaceEntries([entry1]); - const event2 = withTextBody("hi bob!", withSender("@alice:hs.tld", createEvent("m.room.message", "!def"))); + const event2 = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!def"))); const entry2 = new EventEntry({event: event2, fragmentId: 1, eventIndex: 2}, fragmentIdComparer); timeline.addOrReplaceEntries([entry2]); // 3. add local relation (redaction) @@ -385,6 +400,73 @@ export function tests() { // 5. check the local relation got correctly aggregated const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting); assert.equal(locallyRedacted, true); - } + }, + "add local reaction": async assert => { + const storage = await createMockStorage(); + const pendingEvents = new ObservableArray(); + const timeline = new Timeline({roomId, storage, closeCallback: () => {}, + fragmentIdComparer, pendingEvents, clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + const subscription = timeline.entries.subscribe(noopHandler); + const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))); + timeline.addOrReplaceEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]); + let entry = getIndexFromIterable(timeline.entries, 0); + pendingEvents.append(new PendingEvent({data: { + roomId, + queueIndex: 1, + eventType: "m.reaction", + txnId: "t123", + content: entry.annotate("👋"), + relatedEventId: entry.id + }})); + // poll because turning pending events into entries is done async + const pendingAnnotations = await poll(() => entry.pendingAnnotations); + assert.equal(pendingAnnotations.get("👋"), 1); + }, + "add reaction local removal": async assert => { + // 1. put event and reaction into storage + const storage = await createMockStorage(); + const messageStorageEntry = { + event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)), + fragmentId: 1, eventIndex: 2, roomId, + annotations: { // aggregated like RelationWriter would + "👋": {count: 1, me: true, firstTimestamp: 0} + }, + }; + const messageEntry = new EventEntry(messageStorageEntry, fragmentIdComparer); + const reactionStorageEntry = { + event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)), + fragmentId: 1, eventIndex: 3, roomId + }; + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert(messageStorageEntry); + txn.timelineEvents.insert(reactionStorageEntry); + txn.timelineRelations.add(roomId, messageEntry.id, ANNOTATION_RELATION_TYPE, reactionStorageEntry.event.event_id); + await txn.complete(); + // 2. setup timeline + const pendingEvents = new ObservableArray(); + const timeline = new Timeline({roomId, storage, closeCallback: () => {}, + fragmentIdComparer, pendingEvents, clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + const subscription = timeline.entries.subscribe(noopHandler); + // 3. add message to timeline + timeline.addOrReplaceEntries([messageEntry]); + const entry = getIndexFromIterable(timeline.entries, 0); + assert.equal(entry, messageEntry); + assert.equal(entry.annotations["👋"].count, 1); + // 4. redact reaction + const reactionEntry = await timeline.getOwnAnnotationEntry(entry.id, "👋"); + assert.equal(reactionEntry.id, reactionStorageEntry.event.event_id); + pendingEvents.append(new PendingEvent({data: { + roomId, + queueIndex: 1, + eventType: "m.room.redaction", + txnId: "t123", + content: {}, + relatedEventId: reactionEntry.id + }})); + const pendingAnnotations = await poll(() => entry.pendingAnnotations); // poll because turning pending events into entries is done async + assert.equal(pendingAnnotations.get("👋"), -1); + }, } } diff --git a/src/mocks/event.js b/src/mocks/event.js index 01cff281..62230d94 100644 --- a/src/mocks/event.js +++ b/src/mocks/event.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function createEvent(type, id = null) { - return {type, event_id: id}; +export function createEvent(type, id = null, sender = null) { + return {type, event_id: id, sender}; } export function withContent(content, event) { From 9f99cf4b1e6c992a4b5f145df234a1b73889ca2a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 11:52:09 +0200 Subject: [PATCH 154/213] fix lint in tests --- src/matrix/room/timeline/Timeline.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 415dd0fc..589a9232 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -407,7 +407,7 @@ export function tests() { const timeline = new Timeline({roomId, storage, closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); await timeline.load(new User(bob), "join", new NullLogItem()); - const subscription = timeline.entries.subscribe(noopHandler); + timeline.entries.subscribe(noopHandler); const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))); timeline.addOrReplaceEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]); let entry = getIndexFromIterable(timeline.entries, 0); @@ -448,7 +448,7 @@ export function tests() { const timeline = new Timeline({roomId, storage, closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); await timeline.load(new User(bob), "join", new NullLogItem()); - const subscription = timeline.entries.subscribe(noopHandler); + timeline.entries.subscribe(noopHandler); // 3. add message to timeline timeline.addOrReplaceEntries([messageEntry]); const entry = getIndexFromIterable(timeline.entries, 0); From 5bea8130f23ea36cad79040bf0ec0425bb3b8ce0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 14:39:54 +0200 Subject: [PATCH 155/213] more timeline annotation tests --- src/matrix/room/timeline/Timeline.js | 127 +++++++++++++++++---------- src/mocks/ListObserver.js | 82 +++++++++++++++++ 2 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 src/mocks/ListObserver.js diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 589a9232..41ba9fca 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -334,6 +334,7 @@ import {FragmentIdComparer} from "./FragmentIdComparer.js"; import {poll} from "../../../mocks/poll.js"; import {Clock as MockClock} from "../../../mocks/Clock.js"; import {createMockStorage} from "../../../mocks/Storage.js"; +import {ListObserver} from "../../../mocks/ListObserver.js"; import {createEvent, withTextBody, withContent, withSender} from "../../../mocks/event.js"; import {NullLogItem} from "../../../logging/NullLogger.js"; import {EventEntry} from "./entries/EventEntry.js"; @@ -343,14 +344,6 @@ import {createAnnotation} from "./relations.js"; export function tests() { const fragmentIdComparer = new FragmentIdComparer([]); - const noopHandler = {}; - noopHandler.onAdd = - noopHandler.onUpdate = - noopHandler.onRemove = - noopHandler.onMove = - noopHandler.onReset = - function() {}; - const roomId = "$abc"; const alice = "@alice:hs.tld"; const bob = "@bob:hs.tld"; @@ -369,14 +362,8 @@ export function tests() { return { "adding or replacing entries before subscribing to entries does not lose local relations": async assert => { const pendingEvents = new ObservableArray(); - const timeline = new Timeline({ - roomId, - storage: await createMockStorage(), - closeCallback: () => {}, - fragmentIdComparer, - pendingEvents, - clock: new MockClock(), - }); + const timeline = new Timeline({roomId, storage: await createMockStorage(), + closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); // 1. load timeline await timeline.load(new User(alice), "join", new NullLogItem()); // 2. test replaceEntries and addOrReplaceEntries don't fail @@ -396,21 +383,22 @@ export function tests() { relatedEventId: event2.event_id }})); // 4. subscribe (it's now safe to iterate timeline.entries) - timeline.entries.subscribe(noopHandler); + timeline.entries.subscribe(new ListObserver()); // 5. check the local relation got correctly aggregated const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting); assert.equal(locallyRedacted, true); }, - "add local reaction": async assert => { - const storage = await createMockStorage(); + "add and remove local reaction, and cancel again": async assert => { + // 1. setup timeline with message const pendingEvents = new ObservableArray(); - const timeline = new Timeline({roomId, storage, closeCallback: () => {}, - fragmentIdComparer, pendingEvents, clock: new MockClock()}); + const timeline = new Timeline({roomId, storage: await createMockStorage(), + closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); await timeline.load(new User(bob), "join", new NullLogItem()); - timeline.entries.subscribe(noopHandler); + timeline.entries.subscribe(new ListObserver()); const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))); timeline.addOrReplaceEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]); let entry = getIndexFromIterable(timeline.entries, 0); + // 2. add local reaction pendingEvents.append(new PendingEvent({data: { roomId, queueIndex: 1, @@ -419,44 +407,89 @@ export function tests() { content: entry.annotate("👋"), relatedEventId: entry.id }})); - // poll because turning pending events into entries is done async - const pendingAnnotations = await poll(() => entry.pendingAnnotations); - assert.equal(pendingAnnotations.get("👋"), 1); + await poll(() => timeline.entries.length === 2); + assert.equal(entry.pendingAnnotations.get("👋"), 1); + const reactionEntry = getIndexFromIterable(timeline.entries, 1); + // 3. add redaction to timeline + pendingEvents.append(new PendingEvent({data: { + roomId, + queueIndex: 2, + eventType: "m.room.redaction", + txnId: "t456", + content: {}, + relatedTxnId: reactionEntry.id + }})); + await poll(() => timeline.entries.length === 3); + assert.equal(entry.pendingAnnotations.get("👋"), 0); + // 4. cancel redaction + pendingEvents.remove(1); + await poll(() => timeline.entries.length === 2); + assert.equal(entry.pendingAnnotations.get("👋"), 1); + // 5. cancel reaction + pendingEvents.remove(0); + await poll(() => timeline.entries.length === 1); + assert(!entry.pendingAnnotations); }, - "add reaction local removal": async assert => { + "getOwnAnnotationEntry": async assert => { + const messageId = "!abc"; + const reactionId = "!def"; // 1. put event and reaction into storage const storage = await createMockStorage(); - const messageStorageEntry = { + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({ + event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)), + fragmentId: 1, eventIndex: 1, roomId + }); + txn.timelineRelations.add(roomId, messageId, ANNOTATION_RELATION_TYPE, reactionId); + await txn.complete(); + // 2. setup the timeline + const timeline = new Timeline({roomId, storage, closeCallback: () => {}, + fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + // 3. get the own annotation out + const reactionEntry = await timeline.getOwnAnnotationEntry(messageId, "👋"); + assert.equal(reactionEntry.id, reactionId); + assert.equal(reactionEntry.relation.key, "👋"); + }, + "remote reaction": async assert => { + const storage = await createMockStorage(); + const messageEntry = new EventEntry({ event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)), fragmentId: 1, eventIndex: 2, roomId, annotations: { // aggregated like RelationWriter would "👋": {count: 1, me: true, firstTimestamp: 0} }, - }; - const messageEntry = new EventEntry(messageStorageEntry, fragmentIdComparer); - const reactionStorageEntry = { - event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)), - fragmentId: 1, eventIndex: 3, roomId - }; - const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); - txn.timelineEvents.insert(messageStorageEntry); - txn.timelineEvents.insert(reactionStorageEntry); - txn.timelineRelations.add(roomId, messageEntry.id, ANNOTATION_RELATION_TYPE, reactionStorageEntry.event.event_id); - await txn.complete(); + }, fragmentIdComparer); // 2. setup timeline const pendingEvents = new ObservableArray(); - const timeline = new Timeline({roomId, storage, closeCallback: () => {}, - fragmentIdComparer, pendingEvents, clock: new MockClock()}); + const timeline = new Timeline({roomId, storage: await createMockStorage(), + closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); await timeline.load(new User(bob), "join", new NullLogItem()); - timeline.entries.subscribe(noopHandler); + timeline.entries.subscribe(new ListObserver()); // 3. add message to timeline timeline.addOrReplaceEntries([messageEntry]); const entry = getIndexFromIterable(timeline.entries, 0); assert.equal(entry, messageEntry); assert.equal(entry.annotations["👋"].count, 1); - // 4. redact reaction - const reactionEntry = await timeline.getOwnAnnotationEntry(entry.id, "👋"); - assert.equal(reactionEntry.id, reactionStorageEntry.event.event_id); + }, + "remove remote reaction": async assert => { + // 1. setup timeline + const pendingEvents = new ObservableArray(); + const timeline = new Timeline({roomId, storage: await createMockStorage(), + closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + timeline.entries.subscribe(new ListObserver()); + // 2. add message and reaction to timeline + const messageEntry = new EventEntry({ + event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)), + fragmentId: 1, eventIndex: 2, roomId, + }, fragmentIdComparer); + const reactionEntry = new EventEntry({ + event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)), + fragmentId: 1, eventIndex: 3, roomId + }, fragmentIdComparer); + timeline.addOrReplaceEntries([messageEntry, reactionEntry]); + // 3. redact reaction pendingEvents.append(new PendingEvent({data: { roomId, queueIndex: 1, @@ -465,8 +498,8 @@ export function tests() { content: {}, relatedEventId: reactionEntry.id }})); - const pendingAnnotations = await poll(() => entry.pendingAnnotations); // poll because turning pending events into entries is done async - assert.equal(pendingAnnotations.get("👋"), -1); + await poll(() => timeline.entries.length >= 3); + assert.equal(messageEntry.pendingAnnotations.get("👋"), -1); }, - } + }; } diff --git a/src/mocks/ListObserver.js b/src/mocks/ListObserver.js new file mode 100644 index 00000000..d5ae2f62 --- /dev/null +++ b/src/mocks/ListObserver.js @@ -0,0 +1,82 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class ListObserver { + constructor() { + this._queuesPerType = new Map(); + } + + _nextEvent(type) { + const queue = this._queuesPerType.get(type); + if (!queue) { + queue = []; + this._queuesPerType.set(type, queue); + } + return new Promise(resolve => { + queue.push(resolve); + }); + } + + nextAdd() { + return this._nextEvent("add"); + } + + nextUpdate() { + return this._nextEvent("update"); + } + + nextRemove() { + return this._nextEvent("remove"); + } + + nextMove() { + return this._nextEvent("move"); + } + + nextReset() { + return this._nextEvent("reset"); + } + + _popQueue(type) { + const queue = this._queuesPerType.get(type); + return queue?.unshift(); + } + + onReset(list) { + const resolve = this._popQueue("reset"); + resolve && resolve(); + } + + onAdd(index, value) { + const resolve = this._popQueue("add"); + resolve && resolve({index, value}); + } + + onUpdate(index, value, params) { + const resolve = this._popQueue("update"); + resolve && resolve({index, value, params}); + } + + onRemove(index, value) { + const resolve = this._popQueue("remove"); + resolve && resolve({index, value}); + } + + onMove(fromIdx, toIdx, value) { + const resolve = this._popQueue("move"); + resolve && resolve({fromIdx, toIdx, value}); + } +} From 0703cf891521547faccdd83bc02bba1c5ae39460 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 15:06:49 +0200 Subject: [PATCH 156/213] cleanup --- src/matrix/room/timeline/Timeline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 41ba9fca..1258128b 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -419,6 +419,7 @@ export function tests() { content: {}, relatedTxnId: reactionEntry.id }})); + // TODO: await nextUpdate here with ListObserver, to ensure entry emits an update when pendingAnnotations changes await poll(() => timeline.entries.length === 3); assert.equal(entry.pendingAnnotations.get("👋"), 0); // 4. cancel redaction @@ -452,7 +453,6 @@ export function tests() { assert.equal(reactionEntry.relation.key, "👋"); }, "remote reaction": async assert => { - const storage = await createMockStorage(); const messageEntry = new EventEntry({ event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)), fragmentId: 1, eventIndex: 2, roomId, From 11fba12083ce7dba53af92155993d512fee7eaaa Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 15:09:14 +0200 Subject: [PATCH 157/213] add tests for remote reaction target being added after pending event --- src/matrix/room/timeline/Timeline.js | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 1258128b..257d2c3b 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -501,5 +501,68 @@ export function tests() { await poll(() => timeline.entries.length >= 3); assert.equal(messageEntry.pendingAnnotations.get("👋"), -1); }, + "local reaction gets applied after remote echo is added to timeline": async assert => { + const messageEntry = new EventEntry({event: withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))), + fragmentId: 1, eventIndex: 2}, fragmentIdComparer); + // 1. setup timeline + const pendingEvents = new ObservableArray(); + const timeline = new Timeline({roomId, storage: await createMockStorage(), + closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + timeline.entries.subscribe(new ListObserver()); + // 2. add local reaction + pendingEvents.append(new PendingEvent({data: { + roomId, + queueIndex: 1, + eventType: "m.reaction", + txnId: "t123", + content: messageEntry.annotate("👋"), + relatedEventId: messageEntry.id + }})); + await poll(() => timeline.entries.length === 1); + // 3. add remote reaction target + timeline.addOrReplaceEntries([messageEntry]); + await poll(() => timeline.entries.length === 2); + const entry = getIndexFromIterable(timeline.entries, 0); + assert.equal(entry, messageEntry); + assert.equal(entry.pendingAnnotations.get("👋"), 1); + }, + "local reaction removal gets applied after remote echo is added to timeline with reaction not loaded": async assert => { + const messageId = "!abc"; + const reactionId = "!def"; + // 1. put reaction in storage + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({ + event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)), + fragmentId: 1, eventIndex: 3, roomId + }); + await txn.complete(); + // 2. setup timeline + const pendingEvents = new ObservableArray(); + const timeline = new Timeline({roomId, storage, closeCallback: () => {}, + fragmentIdComparer, pendingEvents, clock: new MockClock()}); + await timeline.load(new User(bob), "join", new NullLogItem()); + timeline.entries.subscribe(new ListObserver()); + // 3. add local redaction for reaction + pendingEvents.append(new PendingEvent({data: { + roomId, + queueIndex: 1, + eventType: "m.room.redaction", + txnId: "t123", + content: {}, + relatedEventId: reactionId + }})); + await poll(() => timeline.entries.length === 1); + // 4. add reaction target + timeline.addOrReplaceEntries([new EventEntry({ + event: withTextBody("hi bob!", createEvent("m.room.message", messageId, alice)), + fragmentId: 1, eventIndex: 2}, fragmentIdComparer) + ]); + await poll(() => timeline.entries.length === 2); + // 5. check that redaction was linked to reaction target + const entry = getIndexFromIterable(timeline.entries, 0); + assert.equal(entry.pendingAnnotations.get("👋"), -1); + }, }; } From 1fc1d2c79bf1796476cb7592cd7b4accadb9b0ce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 18 Jun 2021 15:09:34 +0200 Subject: [PATCH 158/213] fix lint --- src/mocks/ListObserver.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mocks/ListObserver.js b/src/mocks/ListObserver.js index d5ae2f62..29104caf 100644 --- a/src/mocks/ListObserver.js +++ b/src/mocks/ListObserver.js @@ -20,7 +20,7 @@ export class ListObserver { } _nextEvent(type) { - const queue = this._queuesPerType.get(type); + let queue = this._queuesPerType.get(type); if (!queue) { queue = []; this._queuesPerType.set(type, queue); @@ -55,7 +55,7 @@ export class ListObserver { return queue?.unshift(); } - onReset(list) { + onReset() { const resolve = this._popQueue("reset"); resolve && resolve(); } From 12305be06a11affaca79848ff6359078f421108b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sat, 19 Jun 2021 16:01:02 +0530 Subject: [PATCH 159/213] Fix issue #397 Signed-off-by: RMidhunSuresh --- .../room/timeline/tiles/RoomMemberTile.js | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index a4f0268d..ce41f031 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -33,7 +33,10 @@ export class RoomMemberTile extends SimpleTile { if (content.avatar_url !== prevContent.avatar_url) { return `${senderName} changed their avatar`; } else if (content.displayname !== prevContent.displayname) { - return `${prevContent.displayname} changed their name to ${content.displayname}`; + if (!content.displayname) { + return `${stateKey} removed their name (${prevContent.displayname})`; + } + return `${prevContent.displayname ?? stateKey} changed their name to ${content.displayname}`; } } else if (membership === "join") { return `${targetName} joined the room`; @@ -59,3 +62,28 @@ export class RoomMemberTile extends SimpleTile { return `${sender} membership changed to ${content.membership}`; } } + +export function tests() { + return { + "user removes display name": (assert) => { + const tile = new RoomMemberTile({ + entry: { + prevContent: {displayname: "foo", membership: "join"}, + content: {membership: "join"}, + stateKey: "foo@bar.com", + }, + }); + assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)"); + }, + "user without display name sets a new display name": (assert) => { + const tile = new RoomMemberTile({ + entry: { + prevContent: {membership: "join"}, + content: {displayname: "foo", membership: "join" }, + stateKey: "foo@bar.com", + }, + }); + assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo"); + }, + }; +} From 81f06f565ebcf2b7d84253e55fb5d5747a1fa66b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 21 Jun 2021 17:26:08 +0200 Subject: [PATCH 160/213] write tests for AsyncMappedList --- src/mocks/ListObserver.js | 63 +++++++++----------------- src/observable/list/AsyncMappedList.js | 49 +++++++++++++++++++- src/observable/list/ObservableArray.js | 7 +++ 3 files changed, 76 insertions(+), 43 deletions(-) diff --git a/src/mocks/ListObserver.js b/src/mocks/ListObserver.js index 29104caf..3902ebef 100644 --- a/src/mocks/ListObserver.js +++ b/src/mocks/ListObserver.js @@ -16,67 +16,46 @@ limitations under the License. export class ListObserver { constructor() { - this._queuesPerType = new Map(); + this._queue = []; + this._backlog = []; } - _nextEvent(type) { - let queue = this._queuesPerType.get(type); - if (!queue) { - queue = []; - this._queuesPerType.set(type, queue); + next() { + if (this._backlog.length) { + return Promise.resolve(this._backlog.shift()); + } else { + return new Promise(resolve => { + this._queue.push(resolve); + }); } - return new Promise(resolve => { - queue.push(resolve); - }); } - nextAdd() { - return this._nextEvent("add"); - } - - nextUpdate() { - return this._nextEvent("update"); - } - - nextRemove() { - return this._nextEvent("remove"); - } - - nextMove() { - return this._nextEvent("move"); - } - - nextReset() { - return this._nextEvent("reset"); - } - - _popQueue(type) { - const queue = this._queuesPerType.get(type); - return queue?.unshift(); + _fullfillNext(value) { + if (this._queue.length) { + const resolve = this._queue.shift(); + resolve(value); + } else { + this._backlog.push(value); + } } onReset() { - const resolve = this._popQueue("reset"); - resolve && resolve(); + this._fullfillNext({type: "reset"}); } onAdd(index, value) { - const resolve = this._popQueue("add"); - resolve && resolve({index, value}); + this._fullfillNext({type: "add", index, value}); } onUpdate(index, value, params) { - const resolve = this._popQueue("update"); - resolve && resolve({index, value, params}); + this._fullfillNext({type: "update", index, value, params}); } onRemove(index, value) { - const resolve = this._popQueue("remove"); - resolve && resolve({index, value}); + this._fullfillNext({type: "remove", index, value}); } onMove(fromIdx, toIdx, value) { - const resolve = this._popQueue("move"); - resolve && resolve({fromIdx, toIdx, value}); + this._fullfillNext({type: "move", fromIdx, toIdx, value}); } } diff --git a/src/observable/list/AsyncMappedList.js b/src/observable/list/AsyncMappedList.js index 12ef3c42..604d8e94 100644 --- a/src/observable/list/AsyncMappedList.js +++ b/src/observable/list/AsyncMappedList.js @@ -143,8 +143,55 @@ class ResetEvent { } } +import {ObservableArray} from "./ObservableArray.js"; +import {ListObserver} from "../../mocks/ListObserver.js"; + export function tests() { return { - + "events are emitted in order": async assert => { + const double = n => n * n; + const source = new ObservableArray(); + const mapper = new AsyncMappedList(source, async n => { + await new Promise(r => setTimeout(r, n)); + return {n: double(n)}; + }, (o, params, n) => { + o.n = double(n); + }); + const observer = new ListObserver(); + mapper.subscribe(observer); + source.append(2); // will sleep this amount, so second append would take less time + source.append(1); + source.update(0, 7, "lucky seven") + source.remove(0); + { + const {type, index, value} = await observer.next(); + assert.equal(mapper.length, 1); + assert.equal(type, "add"); + assert.equal(index, 0); + assert.equal(value.n, 4); + } + { + const {type, index, value} = await observer.next(); + assert.equal(mapper.length, 2); + assert.equal(type, "add"); + assert.equal(index, 1); + assert.equal(value.n, 1); + } + { + const {type, index, value, params} = await observer.next(); + assert.equal(mapper.length, 2); + assert.equal(type, "update"); + assert.equal(index, 0); + assert.equal(value.n, 49); + assert.equal(params, "lucky seven"); + } + { + const {type, index, value} = await observer.next(); + assert.equal(mapper.length, 1); + assert.equal(type, "remove"); + assert.equal(index, 0); + assert.equal(value.n, 49); + } + } } } diff --git a/src/observable/list/ObservableArray.js b/src/observable/list/ObservableArray.js index 37802587..0f9ee99d 100644 --- a/src/observable/list/ObservableArray.js +++ b/src/observable/list/ObservableArray.js @@ -44,6 +44,13 @@ export class ObservableArray extends BaseObservableList { this.emitAdd(idx, item); } + update(idx, item, params = null) { + if (idx < this._items.length) { + this._items[idx] = item; + this.emitUpdate(idx, item, params); + } + } + get array() { return this._items; } From d1345d0f83e7cec03fa478dbab83b3fdc8f30583 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 21 Jun 2021 17:52:02 +0200 Subject: [PATCH 161/213] write test for redaction in RelationWriter --- .../timeline/persistence/RelationWriter.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 853dcb0b..09c56f2a 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -251,3 +251,43 @@ const _REDACT_KEEP_CONTENT_MAP = { 'm.room.aliases': {'aliases': 1}, }; // end of matrix-js-sdk code + +import {createMockStorage} from "../../../../mocks/Storage.js"; +import {createEvent, withTextBody, withRedacts} from "../../../../mocks/event.js"; +import {FragmentIdComparer} from "../FragmentIdComparer.js"; +import {NullLogItem} from "../../../../logging/NullLogger.js"; + +export function tests() { + const fragmentIdComparer = new FragmentIdComparer([]); + const roomId = "$abc"; + const alice = "@alice:hs.tld"; + const bob = "@bob:hs.tld"; + + return { + "apply redaction": async assert => { + const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const reason = "nonsense, cats are the best!"; + const redaction = withRedacts(event.event_id, reason, createEvent("m.room.redaction", "!def", alice)); + const redactionEntry = new EventEntry({fragmentId: 1, eventIndex: 3, event: redaction, roomId}, fragmentIdComparer); + const relationWriter = new RelationWriter({roomId, ownUserId: bob, fragmentIdComparer}); + + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId}); + const updatedEntries = await relationWriter.writeRelation(redactionEntry, txn, new NullLogItem()); + await txn.complete(); + + assert.equal(updatedEntries.length, 1); + const redactedMessage = updatedEntries[0]; + assert.equal(redactedMessage.id, "!abc"); + assert.equal(redactedMessage.content.body, undefined); + assert.equal(redactedMessage.redactionReason, reason); + + const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]); + const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc"); + await readTxn.complete(); + assert.equal(storedMessage.event.content.body, undefined); + assert.equal(storedMessage.event.unsigned.redacted_because.content.reason, reason); + } + } +} From 0e750db9aeaf775d3f8e4a91f1873ca8a066f1f5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 21 Jun 2021 18:16:21 +0200 Subject: [PATCH 162/213] write unit tests for (re)aggregating annotations in RelationWriter --- .../timeline/persistence/RelationWriter.js | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 09c56f2a..b56988be 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -253,7 +253,8 @@ const _REDACT_KEEP_CONTENT_MAP = { // end of matrix-js-sdk code import {createMockStorage} from "../../../../mocks/Storage.js"; -import {createEvent, withTextBody, withRedacts} from "../../../../mocks/event.js"; +import {createEvent, withTextBody, withRedacts, withContent} from "../../../../mocks/event.js"; +import {createAnnotation} from "../relations.js"; import {FragmentIdComparer} from "../FragmentIdComparer.js"; import {NullLogItem} from "../../../../logging/NullLogger.js"; @@ -288,6 +289,98 @@ export function tests() { await readTxn.complete(); assert.equal(storedMessage.event.content.body, undefined); assert.equal(storedMessage.event.unsigned.redacted_because.content.reason, reason); - } + }, + "aggregate reaction": async assert => { + const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const reaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice)); + reaction.origin_server_ts = 5; + const reactionEntry = new EventEntry({event: reaction, roomId}, fragmentIdComparer); + const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer}); + + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId}); + const updatedEntries = await relationWriter.writeRelation(reactionEntry, txn, new NullLogItem()); + await txn.complete(); + + assert.equal(updatedEntries.length, 1); + const reactedMessage = updatedEntries[0]; + assert.equal(reactedMessage.id, "!abc"); + const annotation = reactedMessage.annotations["🐶"]; + assert.equal(annotation.me, true); + assert.equal(annotation.count, 1); + assert.equal(annotation.firstTimestamp, 5); + + const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]); + const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc"); + await readTxn.complete(); + assert(storedMessage.annotations["🐶"]); + }, + "aggregate second reaction": async assert => { + const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const reaction1 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice)); + reaction1.origin_server_ts = 5; + const reaction1Entry = new EventEntry({event: reaction1, roomId}, fragmentIdComparer); + const reaction2 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob)); + reaction2.origin_server_ts = 10; + const reaction2Entry = new EventEntry({event: reaction2, roomId}, fragmentIdComparer); + const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer}); + + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId}); + await relationWriter.writeRelation(reaction1Entry, txn, new NullLogItem()); + const updatedEntries = await relationWriter.writeRelation(reaction2Entry, txn, new NullLogItem()); + await txn.complete(); + + assert.equal(updatedEntries.length, 1); + + const reactedMessage = updatedEntries[0]; + assert.equal(reactedMessage.id, "!abc"); + const annotation = reactedMessage.annotations["🐶"]; + assert.equal(annotation.me, true); + assert.equal(annotation.count, 2); + assert.equal(annotation.firstTimestamp, 5); + }, + "redact second reaction": async assert => { + const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const myReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice)); + myReaction.origin_server_ts = 5; + const bobReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob)); + bobReaction.origin_server_ts = 10; + const myReactionRedaction = withRedacts(myReaction.event_id, "", createEvent("m.room.redaction", "!pol", alice)); + + const myReactionEntry = new EventEntry({event: myReaction, roomId}, fragmentIdComparer); + const bobReactionEntry = new EventEntry({event: bobReaction, roomId}, fragmentIdComparer); + const myReactionRedactionEntry = new EventEntry({event: myReactionRedaction, roomId}, fragmentIdComparer); + const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer}); + + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId}); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReaction, roomId}); + await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem()); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 4, event: bobReaction, roomId}); + await relationWriter.writeRelation(bobReactionEntry, txn, new NullLogItem()); + const updatedEntries = await relationWriter.writeRelation(myReactionRedactionEntry, txn, new NullLogItem()); + await txn.complete(); + + assert.equal(updatedEntries.length, 2); + + const redactedReaction = updatedEntries[0]; + assert.equal(redactedReaction.id, "!def"); + const reaggregatedMessage = updatedEntries[1]; + assert.equal(reaggregatedMessage.id, "!abc"); + const annotation = reaggregatedMessage.annotations["🐶"]; + assert.equal(annotation.me, false); + assert.equal(annotation.count, 1); + assert.equal(annotation.firstTimestamp, 10); + + const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]); + const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc"); + await readTxn.complete(); + assert.equal(storedMessage.annotations["🐶"].count, 1); + }, + } } From 616d701ebb9d9bee5b11720b9238704ec42c4bd4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 21 Jun 2021 19:02:42 +0200 Subject: [PATCH 163/213] add test that redaction for non-sending event aborts it --- src/matrix/room/sending/SendQueue.js | 33 ++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 041b1aef..9d6af88e 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -39,7 +39,7 @@ export class SendQueue { const pendingEvent = new PendingEvent({ data, remove: () => this._removeEvent(pendingEvent), - emitUpdate: () => this._pendingEvents.update(pendingEvent), + emitUpdate: params => this._pendingEvents.update(pendingEvent, params), attachments }); return pendingEvent; @@ -326,7 +326,8 @@ export class SendQueue { import {HomeServer as MockHomeServer} from "../../../mocks/HomeServer.js"; import {createMockStorage} from "../../../mocks/Storage.js"; -import {NullLogger} from "../../../logging/NullLogger.js"; +import {ListObserver} from "../../../mocks/ListObserver.js"; +import {NullLogger, NullLogItem} from "../../../logging/NullLogger.js"; import {createEvent, withTextBody, withTxnId} from "../../../mocks/event.js"; import {poll} from "../../../mocks/poll.js"; @@ -362,6 +363,34 @@ export function tests() { const sendRequest2 = await poll(() => hs.requests.send[1]); sendRequest2.respond({event_id: event2.event_id}); await poll(() => !queue._isSending); + }, + "redaction of pending event that hasn't started sending yet aborts it": async assert => { + const queue = new SendQueue({ + roomId: "!abc", + storage: await createMockStorage(), + hsApi: new MockHomeServer().api + }); + // first, enqueue a message that will be attempted to send, but we don't respond + await queue.enqueueEvent("m.room.message", {body: "hello!"}, null, new NullLogItem()); + + const observer = new ListObserver(); + queue.pendingEvents.subscribe(observer); + await queue.enqueueEvent("m.room.message", {body: "...world"}, null, new NullLogItem()); + let txnId; + { + const {type, index, value} = await observer.next(); + assert.equal(type, "add"); + assert.equal(index, 1); + assert.equal(typeof value.txnId, "string"); + txnId = value.txnId; + } + await queue.enqueueRedaction(txnId, null, new NullLogItem()); + { + const {type, value, index} = await observer.next(); + assert.equal(type, "remove"); + assert.equal(index, 1); + assert.equal(txnId, value.txnId); + } } } } From b153613200ee491b0b92f90b160f1d0f57d50d24 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:41:28 +0200 Subject: [PATCH 164/213] determine toggle state correctly with both pending redaction & reaction --- .../room/timeline/ReactionsViewModel.js | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 8bf04d4f..1554ceb5 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -16,8 +16,8 @@ limitations under the License. import {ObservableMap} from "../../../../observable/map/ObservableMap.js"; export class ReactionsViewModel { - constructor(parentEntry) { - this._parentEntry = parentEntry; + constructor(parentTile) { + this._parentTile = parentTile; this._map = new ObservableMap(); this._reactions = this._map.sortValues((a, b) => a._compare(b)); } @@ -34,7 +34,7 @@ export class ReactionsViewModel { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentTile)); } } } @@ -47,7 +47,7 @@ export class ReactionsViewModel { this._map.update(key); } } else { - this._map.add(key, new ReactionViewModel(key, null, count, this._parentEntry)); + this._map.add(key, new ReactionViewModel(key, null, count, this._parentTile)); } } } @@ -71,14 +71,18 @@ export class ReactionsViewModel { get reactions() { return this._reactions; } + + getReactionViewModelForKey(key) { + return this._map.get(key); + } } class ReactionViewModel { - constructor(key, annotation, pendingCount, parentEntry) { + constructor(key, annotation, pendingCount, parentTile) { this._key = key; this._annotation = annotation; this._pendingCount = pendingCount; - this._parentEntry = parentEntry; + this._parentTile = parentTile; this._isToggling = false; } @@ -154,24 +158,38 @@ class ReactionViewModel { } } - async toggleReaction() { - if (this._isToggling) { - return; - } - this._isToggling = true; - try { - // TODO: should some of this go into BaseMessageTile? - const haveLocalRedaction = this.isPending && this._pendingCount <= 0; - const havePendingReaction = this.isPending && this._pendingCount > 0; - const haveRemoteReaction = this._annotation?.me; - const haveReaction = havePendingReaction || (haveRemoteReaction && !haveLocalRedaction); - if (haveReaction) { - await this._parentEntry.redactReaction(this.key); - } else { - await this._parentEntry.react(this.key); + toggleReaction(log = null) { + return this._parentTile.logger.wrapOrRun(log, "toggleReaction", async log => { + if (this._isToggling) { + log.set("busy", true); + return; } - } finally { - this._isToggling = false; + this._isToggling = true; + try { + // determine whether if everything pending is sent, if we have a + // reaction or not. This depends on the order of the pending events ofcourse, + // which we don't have access to here, but we assume that a redaction comes first + // if we have a remote reaction + const {isPending} = this; + const haveRemoteReaction = this._annotation?.me; + const haveLocalRedaction = isPending && this._pendingCount <= 0; + const haveLocalReaction = isPending && this._pendingCount >= 0; + const haveReaction = (haveRemoteReaction && !haveLocalRedaction) || + // if remote, then assume redaction comes first and reaction last, so final state is reacted + (haveRemoteReaction && haveLocalRedaction && haveLocalReaction) || + (!haveRemoteReaction && !haveLocalRedaction && haveLocalReaction); + log.set({status: haveReaction ? "redact" : "react", haveLocalRedaction, haveLocalReaction, haveRemoteReaction, haveReaction, remoteCount: this._annotation?.count, pendingCount: this._pendingCount}); + if (haveReaction) { + await this._parentTile.redactReaction(this.key, log); + } else { + await this._parentTile.react(this.key, log); + } + } finally { + this._isToggling = false; + } + }); + } +} } } } From 8991632105959049f81c913da6de4408c8fa5e87 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:42:16 +0200 Subject: [PATCH 165/213] add redaction mock utility fn --- src/mocks/event.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mocks/event.js b/src/mocks/event.js index 62230d94..a4a9e094 100644 --- a/src/mocks/event.js +++ b/src/mocks/event.js @@ -33,3 +33,7 @@ export function withTextBody(body, event) { export function withTxnId(txnId, event) { return Object.assign({}, event, {unsigned: {transaction_id: txnId}}); } + +export function withRedacts(redacts, reason, event) { + return Object.assign({redacts, content: {reason}}, event); +} From 4d19f8d21d275e1f62e513aa0167699c57a10a3d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:42:32 +0200 Subject: [PATCH 166/213] this should return any promise returned, otherwise breaks tests --- src/logging/NullLogger.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/logging/NullLogger.js b/src/logging/NullLogger.js index 614dc291..202d01f3 100644 --- a/src/logging/NullLogger.js +++ b/src/logging/NullLogger.js @@ -31,9 +31,9 @@ export class NullLogger { wrapOrRun(item, _, callback) { if (item) { - item.wrap(null, callback); + return item.wrap(null, callback); } else { - this.run(null, callback); + return this.run(null, callback); } } From 18562d30d87751a165db00044a4aa18643b92c4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:43:14 +0200 Subject: [PATCH 167/213] integration tests for local echo of toggling reactions --- .../room/timeline/ReactionsViewModel.js | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 1554ceb5..abe5c2a6 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -190,6 +190,192 @@ class ReactionViewModel { }); } } + +// matrix classes uses in the integration test below +import {User} from "../../../../matrix/User.js"; +import {SendQueue} from "../../../../matrix/room/sending/SendQueue.js"; +import {Timeline} from "../../../../matrix/room/timeline/Timeline.js"; +import {EventEntry} from "../../../../matrix/room/timeline/entries/EventEntry.js"; +import {RelationWriter} from "../../../../matrix/room/timeline/persistence/RelationWriter.js"; +import {FragmentIdComparer} from "../../../../matrix/room/timeline/FragmentIdComparer.js"; +import {createAnnotation} from "../../../../matrix/room/timeline/relations.js"; +// mocks +import {Clock as MockClock} from "../../../../mocks/Clock.js"; +import {createMockStorage} from "../../../../mocks/Storage.js"; +import {ListObserver} from "../../../../mocks/ListObserver.js"; +import {createEvent, withTextBody, withContent} from "../../../../mocks/event.js"; +import {NullLogItem, NullLogger} from "../../../../logging/NullLogger.js"; +import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js"; +// other imports +import {BaseMessageTile} from "./tiles/BaseMessageTile.js"; +import {MappedList} from "../../../../observable/list/MappedList.js"; + +export function tests() { + const fragmentIdComparer = new FragmentIdComparer([]); + const roomId = "$abc"; + const alice = "@alice:hs.tld"; + const bob = "@bob:hs.tld"; + const logger = new NullLogger(); + + function findInIterarable(it, predicate) { + let i = 0; + for (const item of it) { + if (predicate(item, i)) { + return item; + } + i += 1; } + throw new Error("not found"); + } + + function mapMessageEntriesToBaseMessageTile(timeline, queue) { + const room = { + id: roomId, + sendEvent(eventType, content, attachments, log) { + return queue.enqueueEvent(eventType, content, attachments, log); + }, + sendRedaction(eventIdOrTxnId, reason, log) { + return queue.enqueueRedaction(eventIdOrTxnId, reason, log); + } + }; + const tiles = new MappedList(timeline.entries, entry => { + if (entry.eventType === "m.room.message") { + return new BaseMessageTile({entry, room, timeline, platform: {logger}}); + } + return null; + }, (tile, params, entry) => tile?.updateEntry(entry, params)); + return tiles; + } + + // these are more an integration test than unit tests, but fully tests the local echo when toggling + return { + "toggling reaction with own remote reaction": async assert => { + // 1. put message and reaction in storage + const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const myReactionEvent = withContent(createAnnotation(messageEvent.event_id, "🐶"), createEvent("m.reaction", "!def", alice)); + myReactionEvent.origin_server_ts = 5; + const myReactionEntry = new EventEntry({event: myReactionEvent, roomId}, fragmentIdComparer); + const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer}); + const storage = await createMockStorage(); + const txn = await storage.readWriteTxn([ + storage.storeNames.timelineEvents, + storage.storeNames.timelineRelations, + storage.storeNames.timelineFragments + ]); + txn.timelineFragments.add({id: 1, roomId}); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId}); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReactionEvent, roomId}); + await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem()); + await txn.complete(); + // 2. setup queue & timeline + const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); + const timeline = new Timeline({roomId, storage, fragmentIdComparer, + clock: new MockClock(), pendingEvents: queue.pendingEvents}); + // 3. load the timeline, which will load the message with the reaction + await timeline.load(new User(alice), "join", new NullLogItem()); + const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue); + // 4. subscribe to the queue to observe, and the tiles (so we can safely iterate) + const queueObserver = new ListObserver(); + queue.pendingEvents.subscribe(queueObserver); + tiles.subscribe(new ListObserver()); + const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null + const reactionVM = messageTile.reactions.getReactionViewModelForKey("🐶"); + // 5. test toggling + // make sure the preexisting reaction is counted + assert.equal(reactionVM.count, 1); + // 5.1. unset reaction, should redact the pre-existing reaction + await reactionVM.toggleReaction(); + { + assert.equal(reactionVM.count, 0); + const {value: redaction, type} = await queueObserver.next(); + assert.equal("add", type); + assert.equal(redaction.eventType, "m.room.redaction"); + assert.equal(redaction.relatedEventId, myReactionEntry.id); + // SendQueue puts redaction in sending status, as it is first in the queue + assert.equal("update", (await queueObserver.next()).type); + } + // 5.2. set reaction, should send a new reaction as the redaction is already sending + await reactionVM.toggleReaction(); + let reactionIndex; + { + assert.equal(reactionVM.count, 1); + const {value: reaction, type, index} = await queueObserver.next(); + assert.equal("add", type); + assert.equal(reaction.eventType, "m.reaction"); + assert.equal(reaction.relatedEventId, messageEvent.event_id); + reactionIndex = index; + } + // 5.3. unset reaction, should abort the previous pending reaction as it hasn't started sending yet + await reactionVM.toggleReaction(); + { + assert.equal(reactionVM.count, 0); + const {index, type} = await queueObserver.next(); + assert.equal("remove", type); + assert.equal(reactionIndex, index); + } + }, + "toggling reaction without own remote reaction": async assert => { + // 1. put message in storage + const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); + const storage = await createMockStorage(); + + const txn = await storage.readWriteTxn([ + storage.storeNames.timelineEvents, + storage.storeNames.timelineFragments + ]); + txn.timelineFragments.add({id: 1, roomId}); + txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId}); + await txn.complete(); + // 2. setup queue & timeline + const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); + const timeline = new Timeline({roomId, storage, fragmentIdComparer, + clock: new MockClock(), pendingEvents: queue.pendingEvents}); + + // 3. load the timeline, which will load the message with the reaction + await timeline.load(new User(alice), "join", new NullLogItem()); + const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue); + // 4. subscribe to the queue to observe, and the tiles (so we can safely iterate) + const queueObserver = new ListObserver(); + queue.pendingEvents.subscribe(queueObserver); + tiles.subscribe(new ListObserver()); + const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null + // 5. test toggling + assert.equal(messageTile.reactions, null); + // 5.1. set reaction, should send a new reaction as there is none yet + await messageTile.react("🐶"); + // now there should be a reactions view model + const reactionVM = messageTile.reactions.getReactionViewModelForKey("🐶"); + let reactionTxnId; + { + assert.equal(reactionVM.count, 1); + const {value: reaction, type} = await queueObserver.next(); + assert.equal("add", type); + assert.equal(reaction.eventType, "m.reaction"); + assert.equal(reaction.relatedEventId, messageEvent.event_id); + // SendQueue puts reaction in sending status, as it is first in the queue + assert.equal("update", (await queueObserver.next()).type); + reactionTxnId = reaction.txnId; + } + // 5.2. unset reaction, should redact the previous pending reaction as it has started sending already + let redactionIndex; + await reactionVM.toggleReaction(); + { + assert.equal(reactionVM.count, 0); + const {value: redaction, type, index} = await queueObserver.next(); + assert.equal("add", type); + assert.equal(redaction.eventType, "m.room.redaction"); + assert.equal(redaction.relatedTxnId, reactionTxnId); + redactionIndex = index; + } + // 5.3. set reaction, should abort the previous pending redaction as it hasn't started sending yet + await reactionVM.toggleReaction(); + { + assert.equal(reactionVM.count, 1); + const {index, type} = await queueObserver.next(); + assert.equal("remove", type); + assert.equal(redactionIndex, index); + redactionIndex = index; + } + }, } } From 442d4cce03d8cf160eee406a7362ad4defcc1135 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:44:53 +0200 Subject: [PATCH 168/213] make the react/redactReaction promise only return after update happened --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index b73e1c26..5dad667c 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -27,6 +27,7 @@ export class BaseMessageTile extends SimpleTile { if (this._entry.annotations || this._entry.pendingAnnotations) { this._updateReactions(); } + this._pendingReactionChangeCallback = null; } get _mediaRepository() { @@ -125,12 +126,14 @@ export class BaseMessageTile extends SimpleTile { react(key, log = null) { return this.logger.wrapOrRun(log, "react", async log => { const redaction = this._entry.getAnnotationPendingRedaction(key); + const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); if (redaction && !redaction.pendingEvent.hasStartedSending) { log.set("abort_redaction", true); await redaction.pendingEvent.abort(); } else { await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); } + await updatePromise; }); } @@ -143,7 +146,9 @@ export class BaseMessageTile extends SimpleTile { } const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); if (entry) { + const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); await this._room.sendRedaction(entry.id, null, log); + await updatePromise; } else { log.set("no_reaction", true); } @@ -155,12 +160,14 @@ export class BaseMessageTile extends SimpleTile { if (!annotations && !pendingAnnotations) { if (this._reactions) { this._reactions = null; + this._pendingReactionChangeCallback && this._pendingReactionChangeCallback(); } } else { if (!this._reactions) { this._reactions = new ReactionsViewModel(this); } this._reactions.update(annotations, pendingAnnotations); + this._pendingReactionChangeCallback && this._pendingReactionChangeCallback(); } } } From a1d24894ebc56321eec5d0da08e64402d0c4f77c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 11:45:24 +0200 Subject: [PATCH 169/213] this will block if we have a pending redaction & reaction so the reaction won't be aborted --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 5dad667c..edd3370f 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -139,11 +139,6 @@ export class BaseMessageTile extends SimpleTile { redactReaction(key, log = null) { return this.logger.wrapOrRun(log, "redactReaction", async log => { - const redaction = this._entry.getAnnotationPendingRedaction(key); - if (redaction) { - log.set("already_redacting", true); - return; - } const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); if (entry) { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); From 48588687a5c6fd21c9529d14314d9ffc1bc3c02d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 15:38:12 +0200 Subject: [PATCH 170/213] share logic whether have reacted already between basemsgtile & reactvm --- .../room/timeline/ReactionsViewModel.js | 65 +++++++++++++------ .../room/timeline/tiles/BaseMessageTile.js | 61 ++++++++++++++--- .../ui/session/room/timeline/ReactionsView.js | 6 +- 3 files changed, 100 insertions(+), 32 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index abe5c2a6..4ebb7fcc 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -72,7 +72,7 @@ export class ReactionsViewModel { return this._reactions; } - getReactionViewModelForKey(key) { + getReaction(key) { return this._map.get(key); } } @@ -127,10 +127,32 @@ class ReactionViewModel { return this._pendingCount !== null; } - get haveReacted() { + /** @returns {boolean} true if the user has a (pending) reaction + * already for this key, or they have a pending redaction for + * the reaction, false if there is nothing pending and + * the user has not reacted yet. */ + get isActive() { return this._annotation?.me || this.isPending; } + /** @returns {boolean} Whether the user has reacted with this key, + * taking the local reaction and reaction redaction into account. */ + get haveReacted() { + // determine whether if everything pending is sent, if we have a + // reaction or not. This depends on the order of the pending events ofcourse, + // which we don't have access to here, but we assume that a redaction comes first + // if we have a remote reaction + const {isPending} = this; + const haveRemoteReaction = this._annotation?.me; + const haveLocalRedaction = isPending && this._pendingCount <= 0; + const haveLocalReaction = isPending && this._pendingCount >= 0; + const haveReaction = (haveRemoteReaction && !haveLocalRedaction) || + // if remote, then assume redaction comes first and reaction last, so final state is reacted + (haveRemoteReaction && haveLocalRedaction && haveLocalReaction) || + (!haveRemoteReaction && !haveLocalRedaction && haveLocalReaction); + return haveReaction; + } + _compare(other) { // the comparator is also used to test for equality by sortValues, if the comparison returns 0 // given that the firstTimestamp isn't set anymore when the last reaction is removed, @@ -166,23 +188,10 @@ class ReactionViewModel { } this._isToggling = true; try { - // determine whether if everything pending is sent, if we have a - // reaction or not. This depends on the order of the pending events ofcourse, - // which we don't have access to here, but we assume that a redaction comes first - // if we have a remote reaction - const {isPending} = this; - const haveRemoteReaction = this._annotation?.me; - const haveLocalRedaction = isPending && this._pendingCount <= 0; - const haveLocalReaction = isPending && this._pendingCount >= 0; - const haveReaction = (haveRemoteReaction && !haveLocalRedaction) || - // if remote, then assume redaction comes first and reaction last, so final state is reacted - (haveRemoteReaction && haveLocalRedaction && haveLocalReaction) || - (!haveRemoteReaction && !haveLocalRedaction && haveLocalReaction); - log.set({status: haveReaction ? "redact" : "react", haveLocalRedaction, haveLocalReaction, haveRemoteReaction, haveReaction, remoteCount: this._annotation?.count, pendingCount: this._pendingCount}); - if (haveReaction) { - await this._parentTile.redactReaction(this.key, log); + if (this.haveReacted) { + await log.wrap("redactReaction", log => this._parentTile._redactReaction(this.key, log)); } else { - await this._parentTile.react(this.key, log); + await log.wrap("react", log => this._parentTile._react(this.key, log)); } } finally { this._isToggling = false; @@ -247,8 +256,22 @@ export function tests() { return tiles; } - // these are more an integration test than unit tests, but fully tests the local echo when toggling return { + "haveReacted": assert => { + assert.equal(false, new ReactionViewModel("🚀", null, null).haveReacted); + assert.equal(false, new ReactionViewModel("🚀", {me: false, count: 1}, null).haveReacted); + assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 1}, null).haveReacted); + assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 2}, null).haveReacted); + assert.equal(true, new ReactionViewModel("🚀", null, 1).haveReacted); + assert.equal(false, new ReactionViewModel("🚀", {me: true, count: 1}, -1).haveReacted); + // pending count 0 means the remote reaction has been redacted and is sending, then a new reaction was queued + assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 1}, 0).haveReacted); + // should typically not happen without a remote reaction already present, but should still be false + assert.equal(false, new ReactionViewModel("🚀", null, 0).haveReacted); + }, + // these are more an integration test than unit tests, + // but fully test the local echo when toggling and + // the correct send queue modifications happen "toggling reaction with own remote reaction": async assert => { // 1. put message and reaction in storage const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob)); @@ -279,7 +302,7 @@ export function tests() { queue.pendingEvents.subscribe(queueObserver); tiles.subscribe(new ListObserver()); const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null - const reactionVM = messageTile.reactions.getReactionViewModelForKey("🐶"); + const reactionVM = messageTile.reactions.getReaction("🐶"); // 5. test toggling // make sure the preexisting reaction is counted assert.equal(reactionVM.count, 1); @@ -344,7 +367,7 @@ export function tests() { // 5.1. set reaction, should send a new reaction as there is none yet await messageTile.react("🐶"); // now there should be a reactions view model - const reactionVM = messageTile.reactions.getReactionViewModelForKey("🐶"); + const reactionVM = messageTile.reactions.getReaction("🐶"); let reactionTxnId; { assert.equal(reactionVM.count, 1); diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index edd3370f..ca122f68 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -124,26 +124,60 @@ export class BaseMessageTile extends SimpleTile { } react(key, log = null) { - return this.logger.wrapOrRun(log, "react", async log => { - const redaction = this._entry.getAnnotationPendingRedaction(key); - const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); - if (redaction && !redaction.pendingEvent.hasStartedSending) { - log.set("abort_redaction", true); - await redaction.pendingEvent.abort(); - } else { - await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + return this.logger.wrapOrRun(log, "react", log => { + const keyVM = this.reactions?.getReaction(key); + if (keyVM?.haveReacted) { + log.set("already_reacted", true); + return; } - await updatePromise; + return this._react(key, log); }); } + async _react(key, log) { + // This will also block concurently adding multiple reactions, + // but in practice it happens fast enough. + if (this._pendingReactionChangeCallback) { + log.set("ongoing", true); + return; + } + const redaction = this._entry.getAnnotationPendingRedaction(key); + const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); + if (redaction && !redaction.pendingEvent.hasStartedSending) { + log.set("abort_redaction", true); + await redaction.pendingEvent.abort(); + } else { + await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + } + await updatePromise; + this._pendingReactionChangeCallback = null; + } + redactReaction(key, log = null) { + return this.logger.wrapOrRun(log, "redactReaction", log => { + const keyVM = this.reactions?.getReaction(key); + if (!keyVM?.haveReacted) { + log.set("not_yet_reacted", true); + return; + } + return this._redactReaction(key, log); + }); + } + + async _redactReaction(key, log) { + // This will also block concurently removing multiple reactions, + // but in practice it happens fast enough. + if (this._pendingReactionChangeCallback) { + log.set("ongoing", true); + return; + } return this.logger.wrapOrRun(log, "redactReaction", async log => { const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); if (entry) { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); await this._room.sendRedaction(entry.id, null, log); await updatePromise; + this._pendingReactionChangeCallback = null; } else { log.set("no_reaction", true); } @@ -155,6 +189,15 @@ export class BaseMessageTile extends SimpleTile { if (!annotations && !pendingAnnotations) { if (this._reactions) { this._reactions = null; + // The update comes in async because pending events are mapped in the timeline + // to pending event entries using an AsyncMappedMap, because in rare cases, the target + // of a redaction needs to be loaded from storage in order to know for which message + // the reaction needs to be removed. The SendQueue also only adds pending events after + // storing them first. + // This makes that if we want to know the local echo for either react or redactReaction is available, + // we need to async wait for the update call. In theory the update can also be triggered + // by something else than the reaction local echo changing (e.g. from sync), + // but this is very unlikely and deemed good enough for now. this._pendingReactionChangeCallback && this._pendingReactionChangeCallback(); } } else { diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 15d1574a..3cf9ca1e 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -31,9 +31,11 @@ export class ReactionsView extends ListView { class ReactionView extends TemplateView { render(t, vm) { - const haveReacted = vm => vm.haveReacted; return t.button({ - className: {haveReacted, isPending: vm => vm.isPending}, + className: { + active: vm => vm.isActive, + isPending: vm => vm.isPending + }, }, [vm.key, " ", vm => `${vm.count}`]); } From e125599a4770e0ace5217293c498939b4ee00c98 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 17:38:52 +0200 Subject: [PATCH 171/213] prevent decryption result getting lost after reaction updates entry --- src/matrix/room/BaseRoom.js | 2 +- src/matrix/room/Room.js | 2 +- src/matrix/room/timeline/Timeline.js | 67 ++++++++++++++----- src/matrix/room/timeline/entries/BaseEntry.js | 2 + .../room/timeline/entries/EventEntry.js | 13 +++- src/observable/list/SortedArray.js | 12 +++- 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 8df281fd..a1bf7b03 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -299,7 +299,7 @@ export class BaseRoom extends EventEmitter { if (this._timeline) { // these should not be added if not already there this._timeline.replaceEntries(gapResult.updatedEntries); - this._timeline.addOrReplaceEntries(gapResult.entries); + this._timeline.addEntries(gapResult.entries); } }); } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 62b5c3ff..482d167f 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -239,7 +239,7 @@ export class Room extends BaseRoom { if (this._timeline) { // these should not be added if not already there this._timeline.replaceEntries(updatedEntries); - this._timeline.addOrReplaceEntries(newEntries); + this._timeline.addEntries(newEntries); } if (this._observedEvents) { this._observedEvents.updateEvents(updatedEntries); diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 257d2c3b..423643cf 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -182,17 +182,11 @@ export class Timeline { return null; } + /** @package */ updateOwnMember(member) { this._ownMember = member; } - replaceEntries(entries) { - this._addLocalRelationsToNewRemoteEntries(entries); - for (const entry of entries) { - this._remoteEntries.update(entry); - } - } - _addLocalRelationsToNewRemoteEntries(entries) { // because it is not safe to iterate a derived observable collection // before it has any subscriptions, we bail out if this isn't @@ -223,8 +217,22 @@ export class Timeline { } } + // used in replaceEntries + static _entryUpdater(existingEntry, entry) { + entry.updateFrom(existingEntry); + return entry; + } + /** @package */ - addOrReplaceEntries(newEntries) { + replaceEntries(entries) { + this._addLocalRelationsToNewRemoteEntries(entries); + for (const entry of entries) { + this._remoteEntries.getAndUpdate(entry, Timeline._entryUpdater); + } + } + + /** @package */ + addEntries(newEntries) { this._addLocalRelationsToNewRemoteEntries(newEntries); this._remoteEntries.setManySorted(newEntries); } @@ -251,7 +259,7 @@ export class Timeline { )); try { const entries = await readerRequest.complete(); - this.addOrReplaceEntries(entries); + this.addEntries(entries); return entries.length < amount; } finally { this._disposables.disposeTracked(readerRequest); @@ -366,13 +374,13 @@ export function tests() { closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()}); // 1. load timeline await timeline.load(new User(alice), "join", new NullLogItem()); - // 2. test replaceEntries and addOrReplaceEntries don't fail + // 2. test replaceEntries and addEntries don't fail const event1 = withTextBody("hi!", withSender(bob, createEvent("m.room.message", "!abc"))); const entry1 = new EventEntry({event: event1, fragmentId: 1, eventIndex: 1}, fragmentIdComparer); timeline.replaceEntries([entry1]); const event2 = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!def"))); const entry2 = new EventEntry({event: event2, fragmentId: 1, eventIndex: 2}, fragmentIdComparer); - timeline.addOrReplaceEntries([entry2]); + timeline.addEntries([entry2]); // 3. add local relation (redaction) pendingEvents.append(new PendingEvent({data: { roomId, @@ -396,7 +404,7 @@ export function tests() { await timeline.load(new User(bob), "join", new NullLogItem()); timeline.entries.subscribe(new ListObserver()); const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))); - timeline.addOrReplaceEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]); + timeline.addEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]); let entry = getIndexFromIterable(timeline.entries, 0); // 2. add local reaction pendingEvents.append(new PendingEvent({data: { @@ -467,7 +475,7 @@ export function tests() { await timeline.load(new User(bob), "join", new NullLogItem()); timeline.entries.subscribe(new ListObserver()); // 3. add message to timeline - timeline.addOrReplaceEntries([messageEntry]); + timeline.addEntries([messageEntry]); const entry = getIndexFromIterable(timeline.entries, 0); assert.equal(entry, messageEntry); assert.equal(entry.annotations["👋"].count, 1); @@ -488,7 +496,7 @@ export function tests() { event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)), fragmentId: 1, eventIndex: 3, roomId }, fragmentIdComparer); - timeline.addOrReplaceEntries([messageEntry, reactionEntry]); + timeline.addEntries([messageEntry, reactionEntry]); // 3. redact reaction pendingEvents.append(new PendingEvent({data: { roomId, @@ -521,7 +529,7 @@ export function tests() { }})); await poll(() => timeline.entries.length === 1); // 3. add remote reaction target - timeline.addOrReplaceEntries([messageEntry]); + timeline.addEntries([messageEntry]); await poll(() => timeline.entries.length === 2); const entry = getIndexFromIterable(timeline.entries, 0); assert.equal(entry, messageEntry); @@ -555,7 +563,7 @@ export function tests() { }})); await poll(() => timeline.entries.length === 1); // 4. add reaction target - timeline.addOrReplaceEntries([new EventEntry({ + timeline.addEntries([new EventEntry({ event: withTextBody("hi bob!", createEvent("m.room.message", messageId, alice)), fragmentId: 1, eventIndex: 2}, fragmentIdComparer) ]); @@ -564,5 +572,32 @@ export function tests() { const entry = getIndexFromIterable(timeline.entries, 0); assert.equal(entry.pendingAnnotations.get("👋"), -1); }, + "decrypted entry preserves content when receiving other update without decryption": async assert => { + // 1. create encrypted and decrypted entry + const encryptedEntry = new EventEntry({ + event: withContent({ciphertext: "abc"}, createEvent("m.room.encrypted", "!abc", alice)), + fragmentId: 1, eventIndex: 1, roomId + }, fragmentIdComparer); + const decryptedEntry = encryptedEntry.clone(); + decryptedEntry.setDecryptionResult({ + event: withTextBody("hi bob!", createEvent("m.room.message", encryptedEntry.id, encryptedEntry.sender)) + }); + // 2. setup the timeline + const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {}, + fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()}); + await timeline.load(new User(alice), "join", new NullLogItem()); + timeline.addEntries([decryptedEntry]); + const observer = new ListObserver(); + timeline.entries.subscribe(observer); + // 3. replace the entry with one that is not decrypted + // (as would happen when receiving a reaction, + // as it does not rerun the decryption) + // and check that the decrypted content is preserved + timeline.replaceEntries([encryptedEntry]); + const {value, type} = await observer.next(); + assert.equal(type, "update"); + assert.equal(value.eventType, "m.room.message"); + assert.equal(value.content.body, "hi bob!"); + } }; } diff --git a/src/matrix/room/timeline/entries/BaseEntry.js b/src/matrix/room/timeline/entries/BaseEntry.js index 67ba158d..ae3ddf05 100644 --- a/src/matrix/room/timeline/entries/BaseEntry.js +++ b/src/matrix/room/timeline/entries/BaseEntry.js @@ -47,4 +47,6 @@ export class BaseEntry { asEventKey() { return new EventKey(this.fragmentId, this.entryIndex); } + + updateFrom() {} } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 3a6889f7..a0c3799d 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -28,11 +28,20 @@ export class EventEntry extends BaseEventEntry { clone() { const clone = new EventEntry(this._eventEntry, this._fragmentIdComparer); - clone._decryptionResult = this._decryptionResult; - clone._decryptionError = this._decryptionError; + clone.updateFrom(this); return clone; } + updateFrom(other) { + super.updateFrom(other); + if (other._decryptionResult && !this._decryptionResult) { + this._decryptionResult = other._decryptionResult; + } + if (other._decryptionError && !this._decryptionError) { + this._decryptionError = other._decryptionError; + } + } + get event() { return this._eventEntry.event; } diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 39cfcde5..323cf776 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -46,6 +46,16 @@ export class SortedArray extends BaseObservableList { return findAndUpdateInArray(predicate, this._items, this, updater); } + getAndUpdate(item, updater, updateParams = null) { + const idx = this.indexOf(item); + if (idx !== -1) { + const existingItem = this._items[idx]; + const newItem = updater(existingItem, item); + this._items[idx] = newItem; + this.emitUpdate(idx, newItem, updateParams); + } + } + update(item, updateParams = null) { const idx = this.indexOf(item); if (idx !== -1) { @@ -169,4 +179,4 @@ export function tests() { assert.equal(it.next().done, true); } } -} \ No newline at end of file +} From 1a5a64864a91862549ec5b6bf87f2e84e92ab554 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 17:47:18 +0200 Subject: [PATCH 172/213] don't double log redactReaction --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index ca122f68..7accfb12 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -171,7 +171,6 @@ export class BaseMessageTile extends SimpleTile { log.set("ongoing", true); return; } - return this.logger.wrapOrRun(log, "redactReaction", async log => { const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); if (entry) { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); @@ -181,7 +180,6 @@ export class BaseMessageTile extends SimpleTile { } else { log.set("no_reaction", true); } - }); } _updateReactions() { From c585d76ce532e1c40fbf96dfab0f2369bc96781f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 17:47:47 +0200 Subject: [PATCH 173/213] also clear pending reaction promise when an error is thrown --- .../room/timeline/tiles/BaseMessageTile.js | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 7accfb12..e56a160a 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -142,15 +142,18 @@ export class BaseMessageTile extends SimpleTile { return; } const redaction = this._entry.getAnnotationPendingRedaction(key); - const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); - if (redaction && !redaction.pendingEvent.hasStartedSending) { - log.set("abort_redaction", true); - await redaction.pendingEvent.abort(); - } else { - await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + try { + const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); + if (redaction && !redaction.pendingEvent.hasStartedSending) { + log.set("abort_redaction", true); + await redaction.pendingEvent.abort(); + } else { + await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); + } + await updatePromise; + } finally { + this._pendingReactionChangeCallback = null; } - await updatePromise; - this._pendingReactionChangeCallback = null; } redactReaction(key, log = null) { @@ -171,15 +174,18 @@ export class BaseMessageTile extends SimpleTile { log.set("ongoing", true); return; } - const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); - if (entry) { + const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); + if (entry) { + try { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); await this._room.sendRedaction(entry.id, null, log); await updatePromise; + } finally { this._pendingReactionChangeCallback = null; - } else { - log.set("no_reaction", true); } + } else { + log.set("no_reaction", true); + } } _updateReactions() { From 3c7ccc90b29fa97339a897d205bee918cba0e460 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 17:48:21 +0200 Subject: [PATCH 174/213] fix css for reaction view and do some renaming also add some user-select:none --- src/platform/web/ui/css/themes/element/timeline.css | 13 +++++++------ .../web/ui/session/room/timeline/ReactionsView.js | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index af5fb041..fef9598b 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -59,6 +59,7 @@ limitations under the License. .Timeline_message:hover > .Timeline_messageOptions, .Timeline_message.menuOpen > .Timeline_messageOptions { display: block; + user-select: none; } .Timeline_messageAvatar { @@ -106,6 +107,7 @@ limitations under the License. .Timeline_messageBody time { padding: 2px 0 0px 10px; + user-select: none; } .Timeline_messageBody time, .Timeline_messageTime { @@ -135,6 +137,9 @@ limitations under the License. .hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); } .hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); } +.Timeline_messageBody a { + word-break: break-all; +} .Timeline_messageBody .media { display: grid; @@ -231,7 +236,7 @@ only loads when the top comes into view*/ vertical-align: middle; } -.Timeline_messageReactions button.haveReacted { +.Timeline_messageReactions button.active { background-color: #e9fff9; border-color: #0DBD8B; } @@ -241,7 +246,7 @@ only loads when the top comes into view*/ 100% { border-color: #0DBD8B; } } -.Timeline_messageReactions button.haveReacted.isPending { +.Timeline_messageReactions button.active.pending { animation-name: glow-reaction-border; animation-duration: 0.5s; animation-direction: alternate; @@ -265,7 +270,3 @@ only loads when the top comes into view*/ .GapView > :not(:first-child) { margin-left: 12px; } - -.Timeline_messageBody a { - word-break: break-all; -} diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 3cf9ca1e..0f243465 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -34,7 +34,7 @@ class ReactionView extends TemplateView { return t.button({ className: { active: vm => vm.isActive, - isPending: vm => vm.isPending + pending: vm => vm.isPending }, }, [vm.key, " ", vm => `${vm.count}`]); } From 52957beb82085d1d090899cb29f5a1bd2c9f8ffe Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 23 Jun 2021 17:49:27 +0200 Subject: [PATCH 175/213] don't encrypt reactions --- src/matrix/room/sending/SendQueue.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 9d6af88e..90d6a988 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -19,7 +19,7 @@ import {ConnectionError} from "../../error.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; import {REDACTION_TYPE} from "../common.js"; -import {getRelationFromContent} from "../timeline/relations.js"; +import {getRelationFromContent, REACTION_TYPE} from "../timeline/relations.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -296,7 +296,9 @@ export class SendQueue { // wouldn't be able to detect the remote echo already arrived and end up overwriting the new event const maxQueueIndex = Math.max(maxStorageQueueIndex, this._currentQueueIndex); const queueIndex = maxQueueIndex + 1; - const needsEncryption = eventType !== REDACTION_TYPE && !!this._roomEncryption; + const needsEncryption = eventType !== REDACTION_TYPE && + eventType !== REACTION_TYPE && + !!this._roomEncryption; pendingEvent = this._createPendingEvent({ roomId: this._roomId, queueIndex, From e2fd90bdc25e014496cf1dc5f096b004ac29e78b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 24 Jun 2021 13:48:53 +0530 Subject: [PATCH 176/213] Remove Segment import Signed-off-by: RMidhunSuresh --- src/domain/session/RoomGridViewModel.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 6dbc5ea4..dddc603b 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -81,9 +81,9 @@ export class RoomGridViewModel extends ViewModel { _switchToRoom(roomId) { const detailsShown = !!this.navigation.path.get("details")?.value; let path = this.navigation.path.until("rooms"); - path = path.with(new Segment("room", roomId)); + path = path.with(this.navigation.segment("room", roomId)); if (detailsShown) { - path = path.with(new Segment("details", true)); + path = path.with(this.navigation.segment("details", true)); } this.navigation.applyPath(path); } @@ -189,7 +189,6 @@ export class RoomGridViewModel extends ViewModel { import {createNavigation} from "../navigation/index.js"; import {ObservableValue} from "../../observable/ObservableValue.js"; -import { Segment } from "../navigation/Navigation.js"; export function tests() { class RoomVMMock { From a273b25bac86f248c4039c70f13fb372737537d3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 24 Jun 2021 14:52:48 +0530 Subject: [PATCH 177/213] Remove css assumption Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/layout.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index f2474917..fecbbd60 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -72,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { - .SessionView:not(.middle-shown) { + .SessionView:not(.middle-shown):not(.right-shown) { grid-template: "status" auto "left" 1fr / @@ -93,9 +93,9 @@ the layout viewport up without resizing it when the keyboard shows */ 1fr; } - .SessionView:not(.middle-shown) .room-placeholder { display: none; } + .SessionView:not(.middle-shown):not(.right-shown) .room-placeholder { display: none; } .SessionView.middle-shown .LeftPanel { display: none; } - .SessionView.right-shown .middle { display: none; } + .SessionView.right-shown .middle, .SessionView.right-shown .LeftPanel { display: none; } /* show back button */ .middle .close-middle { display: block !important; } From eb35f462141c9abb363a6618580bc4d8039911e4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 24 Jun 2021 14:58:02 +0530 Subject: [PATCH 178/213] Make sure room does exist before creating vm - This will stop the code from throwing when opening /details on UnknownRoomView. Signed-off-by: RMidhunSuresh --- src/domain/session/SessionViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 14bb087e..087b9315 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -261,6 +261,7 @@ export class SessionViewModel extends ViewModel { const enable = !!this.navigation.path.get("details")?.value; if (enable) { const room = this._roomFromNavigation(); + if (!room) { return; } this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room}))); } this.emitChange("roomDetailsViewModel"); From a4a7c231484caf4e9d585d1deb39dda76af503e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 12:26:38 +0200 Subject: [PATCH 179/213] use pending re(d)action timestamp to have stable reaction sorting order also move more logic into the matrix layer, from Reaction(s)ViewModel to PendingAnnotation --- .../room/timeline/ReactionsViewModel.js | 105 ++++++------------ .../room/timeline/tiles/BaseMessageTile.js | 24 +++- src/matrix/room/timeline/PendingAnnotation.js | 76 +++++++++++++ .../room/timeline/PendingAnnotations.js | 74 ------------ src/matrix/room/timeline/Timeline.js | 12 +- .../room/timeline/entries/BaseEventEntry.js | 54 ++++++--- .../room/timeline/entries/EventEntry.js | 88 ++++++++++++++- .../timeline/entries/PendingEventEntry.js | 7 +- 8 files changed, 258 insertions(+), 182 deletions(-) create mode 100644 src/matrix/room/timeline/PendingAnnotation.js delete mode 100644 src/matrix/room/timeline/PendingAnnotations.js diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 4ebb7fcc..886230a5 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -40,14 +40,13 @@ export class ReactionsViewModel { } } if (pendingAnnotations) { - for (const [key, count] of pendingAnnotations.entries()) { + for (const [key, annotation] of pendingAnnotations.entries()) { const reaction = this._map.get(key); if (reaction) { - if (reaction._tryUpdatePending(count)) { - this._map.update(key); - } + reaction._tryUpdatePending(annotation); + this._map.update(key); } else { - this._map.add(key, new ReactionViewModel(key, null, count, this._parentTile)); + this._map.add(key, new ReactionViewModel(key, null, annotation, this._parentTile)); } } } @@ -78,10 +77,10 @@ export class ReactionsViewModel { } class ReactionViewModel { - constructor(key, annotation, pendingCount, parentTile) { + constructor(key, annotation, pending, parentTile) { this._key = key; this._annotation = annotation; - this._pendingCount = pendingCount; + this._pending = pending; this._parentTile = parentTile; this._isToggling = false; } @@ -101,12 +100,12 @@ class ReactionViewModel { return false; } - _tryUpdatePending(pendingCount) { - if (pendingCount !== this._pendingCount) { - this._pendingCount = pendingCount; - return true; + _tryUpdatePending(pending) { + if (!pending && !this._pending) { + return false; } - return false; + this._pending = pending; + return true; } get key() { @@ -114,17 +113,11 @@ class ReactionViewModel { } get count() { - let count = this._pendingCount || 0; - if (this._annotation) { - count += this._annotation.count; - } - return count; + return (this._pending?.count || 0) + (this._annotation?.count || 0); } get isPending() { - // even if pendingCount is 0, - // it means we have both a pending reaction and redaction - return this._pendingCount !== null; + return this._pending !== null; } /** @returns {boolean} true if the user has a (pending) reaction @@ -138,19 +131,19 @@ class ReactionViewModel { /** @returns {boolean} Whether the user has reacted with this key, * taking the local reaction and reaction redaction into account. */ get haveReacted() { - // determine whether if everything pending is sent, if we have a - // reaction or not. This depends on the order of the pending events ofcourse, - // which we don't have access to here, but we assume that a redaction comes first - // if we have a remote reaction - const {isPending} = this; - const haveRemoteReaction = this._annotation?.me; - const haveLocalRedaction = isPending && this._pendingCount <= 0; - const haveLocalReaction = isPending && this._pendingCount >= 0; - const haveReaction = (haveRemoteReaction && !haveLocalRedaction) || - // if remote, then assume redaction comes first and reaction last, so final state is reacted - (haveRemoteReaction && haveLocalRedaction && haveLocalReaction) || - (!haveRemoteReaction && !haveLocalRedaction && haveLocalReaction); - return haveReaction; + // TODO: cleanup + return this._parentTile._entry.haveAnnotation(this.key); + } + + get firstTimestamp() { + let ts = Number.MAX_SAFE_INTEGER; + if (this._annotation) { + ts = Math.min(ts, this._annotation.firstTimestamp); + } + if (this._pending) { + ts = Math.min(ts, this._pending.firstTimestamp); + } + return ts; } _compare(other) { @@ -163,40 +156,16 @@ class ReactionViewModel { if (this.count !== other.count) { return other.count - this.count; } else { - const a = this._annotation; - const b = other._annotation; - if (a && b) { - const cmp = a.firstTimestamp - b.firstTimestamp; - if (cmp === 0) { - return this.key < other.key ? -1 : 1; - } else { - return cmp; - } - } else if (a) { - return -1; - } else { - return 1; + const cmp = this.firstTimestamp - other.firstTimestamp; + if (cmp === 0) { + return this.key < other.key ? -1 : 1; } + return cmp; } } toggleReaction(log = null) { - return this._parentTile.logger.wrapOrRun(log, "toggleReaction", async log => { - if (this._isToggling) { - log.set("busy", true); - return; - } - this._isToggling = true; - try { - if (this.haveReacted) { - await log.wrap("redactReaction", log => this._parentTile._redactReaction(this.key, log)); - } else { - await log.wrap("react", log => this._parentTile._react(this.key, log)); - } - } finally { - this._isToggling = false; - } - }); + return this._parentTile.toggleReaction(this.key, log); } } @@ -257,18 +226,6 @@ export function tests() { } return { - "haveReacted": assert => { - assert.equal(false, new ReactionViewModel("🚀", null, null).haveReacted); - assert.equal(false, new ReactionViewModel("🚀", {me: false, count: 1}, null).haveReacted); - assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 1}, null).haveReacted); - assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 2}, null).haveReacted); - assert.equal(true, new ReactionViewModel("🚀", null, 1).haveReacted); - assert.equal(false, new ReactionViewModel("🚀", {me: true, count: 1}, -1).haveReacted); - // pending count 0 means the remote reaction has been redacted and is sending, then a new reaction was queued - assert.equal(true, new ReactionViewModel("🚀", {me: true, count: 1}, 0).haveReacted); - // should typically not happen without a remote reaction already present, but should still be false - assert.equal(false, new ReactionViewModel("🚀", null, 0).haveReacted); - }, // these are more an integration test than unit tests, // but fully test the local echo when toggling and // the correct send queue modifications happen diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index e56a160a..6e43b138 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -125,8 +125,7 @@ export class BaseMessageTile extends SimpleTile { react(key, log = null) { return this.logger.wrapOrRun(log, "react", log => { - const keyVM = this.reactions?.getReaction(key); - if (keyVM?.haveReacted) { + if (this._entry.haveAnnotation(key)) { log.set("already_reacted", true); return; } @@ -141,7 +140,7 @@ export class BaseMessageTile extends SimpleTile { log.set("ongoing", true); return; } - const redaction = this._entry.getAnnotationPendingRedaction(key); + const redaction = this._entry.pendingAnnotations?.get(key)?.redactionEntry; try { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); if (redaction && !redaction.pendingEvent.hasStartedSending) { @@ -159,7 +158,7 @@ export class BaseMessageTile extends SimpleTile { redactReaction(key, log = null) { return this.logger.wrapOrRun(log, "redactReaction", log => { const keyVM = this.reactions?.getReaction(key); - if (!keyVM?.haveReacted) { + if (!this._entry.haveAnnotation(key)) { log.set("not_yet_reacted", true); return; } @@ -170,11 +169,16 @@ export class BaseMessageTile extends SimpleTile { async _redactReaction(key, log) { // This will also block concurently removing multiple reactions, // but in practice it happens fast enough. + + // TODO: remove this as we'll protect against reentry in the SendQueue if (this._pendingReactionChangeCallback) { log.set("ongoing", true); return; } - const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key); + let entry = this._entry.pendingAnnotations?.get(key)?.annotationEntry; + if (!entry) { + entry = await this._timeline.getOwnAnnotationEntry(this._entry.id, key); + } if (entry) { try { const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); @@ -188,6 +192,16 @@ export class BaseMessageTile extends SimpleTile { } } + toggleReaction(key, log = null) { + return this.logger.wrapOrRun(log, "toggleReaction", async log => { + if (this._entry.haveAnnotation(key)) { + await log.wrap("redactReaction", log => this._redactReaction(key, log)); + } else { + await log.wrap("react", log => this._react(key, log)); + } + }); + } + _updateReactions() { const {annotations, pendingAnnotations} = this._entry; if (!annotations && !pendingAnnotations) { diff --git a/src/matrix/room/timeline/PendingAnnotation.js b/src/matrix/room/timeline/PendingAnnotation.js new file mode 100644 index 00000000..0b14159c --- /dev/null +++ b/src/matrix/room/timeline/PendingAnnotation.js @@ -0,0 +1,76 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class PendingAnnotation { + constructor() { + // TODO: use simple member for reaction and redaction as we can't/shouldn't really have more than 2 entries + // this contains both pending annotation entries, and pending redactions of remote annotation entries + this._entries = []; + } + + get firstTimestamp() { + return this._entries.reduce((ts, e) => { + if (e.isRedaction) { + return ts; + } + return Math.min(e.timestamp, ts); + }, Number.MAX_SAFE_INTEGER); + } + + get annotationEntry() { + return this._entries.find(e => !e.isRedaction); + } + + get redactionEntry() { + return this._entries.find(e => e.isRedaction); + } + + get count() { + return this._entries.reduce((count, e) => { + return count + (e.isRedaction ? -1 : 1); + }, 0); + } + + add(entry) { + this._entries.push(entry); + } + + remove(entry) { + const idx = this._entries.indexOf(entry); + if (idx === -1) { + return false; + } + this._entries.splice(idx, 1); + return true; + } + + get willAnnotate() { + const lastEntry = this._entries.reduce((lastEntry, e) => { + if (!lastEntry || e.pendingEvent.queueIndex > lastEntry.pendingEvent.queueIndex) { + return e; + } + return lastEntry; + }, null); + if (lastEntry) { + return !lastEntry.isRedaction; + } + return false; + } + + get isEmpty() { + return this._entries.length === 0; + } +} diff --git a/src/matrix/room/timeline/PendingAnnotations.js b/src/matrix/room/timeline/PendingAnnotations.js deleted file mode 100644 index 1dd32abd..00000000 --- a/src/matrix/room/timeline/PendingAnnotations.js +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export class PendingAnnotations { - constructor() { - this.aggregatedAnnotations = new Map(); - // this contains both pending annotation entries, and pending redactions of remote annotation entries - this._entries = []; - } - - /** adds either a pending annotation entry, or a remote annotation entry with a pending redaction */ - add(entry) { - const {key} = (entry.redactingEntry || entry).relation; - if (!key) { - return; - } - const count = this.aggregatedAnnotations.get(key) || 0; - const addend = entry.isRedaction ? -1 : 1; - this.aggregatedAnnotations.set(key, count + addend); - this._entries.push(entry); - } - - /** removes either a pending annotation entry, or a remote annotation entry with a pending redaction */ - remove(entry) { - const idx = this._entries.indexOf(entry); - if (idx === -1) { - return; - } - this._entries.splice(idx, 1); - const {key} = (entry.redactingEntry || entry).relation; - let count = this.aggregatedAnnotations.get(key); - if (count !== undefined) { - const addend = entry.isRedaction ? 1 : -1; - count += addend; - this.aggregatedAnnotations.set(key, count); - } - if (!this._entries.length) { - this.aggregatedAnnotations.clear(); - } - } - - findForKey(key) { - return this._entries.find(e => { - if (e.relation?.key === key) { - return e; - } - }); - } - - findRedactionForKey(key) { - return this._entries.find(e => { - if (e.redactingEntry?.relation?.key === key) { - return e; - } - }); - } - - get isEmpty() { - return this._entries.length === 0; - } -} diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 423643cf..8fd84075 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -416,7 +416,7 @@ export function tests() { relatedEventId: entry.id }})); await poll(() => timeline.entries.length === 2); - assert.equal(entry.pendingAnnotations.get("👋"), 1); + assert.equal(entry.pendingAnnotations.get("👋").count, 1); const reactionEntry = getIndexFromIterable(timeline.entries, 1); // 3. add redaction to timeline pendingEvents.append(new PendingEvent({data: { @@ -429,11 +429,11 @@ export function tests() { }})); // TODO: await nextUpdate here with ListObserver, to ensure entry emits an update when pendingAnnotations changes await poll(() => timeline.entries.length === 3); - assert.equal(entry.pendingAnnotations.get("👋"), 0); + assert.equal(entry.pendingAnnotations.get("👋").count, 0); // 4. cancel redaction pendingEvents.remove(1); await poll(() => timeline.entries.length === 2); - assert.equal(entry.pendingAnnotations.get("👋"), 1); + assert.equal(entry.pendingAnnotations.get("👋").count, 1); // 5. cancel reaction pendingEvents.remove(0); await poll(() => timeline.entries.length === 1); @@ -507,7 +507,7 @@ export function tests() { relatedEventId: reactionEntry.id }})); await poll(() => timeline.entries.length >= 3); - assert.equal(messageEntry.pendingAnnotations.get("👋"), -1); + assert.equal(messageEntry.pendingAnnotations.get("👋").count, -1); }, "local reaction gets applied after remote echo is added to timeline": async assert => { const messageEntry = new EventEntry({event: withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))), @@ -533,7 +533,7 @@ export function tests() { await poll(() => timeline.entries.length === 2); const entry = getIndexFromIterable(timeline.entries, 0); assert.equal(entry, messageEntry); - assert.equal(entry.pendingAnnotations.get("👋"), 1); + assert.equal(entry.pendingAnnotations.get("👋").count, 1); }, "local reaction removal gets applied after remote echo is added to timeline with reaction not loaded": async assert => { const messageId = "!abc"; @@ -570,7 +570,7 @@ export function tests() { await poll(() => timeline.entries.length === 2); // 5. check that redaction was linked to reaction target const entry = getIndexFromIterable(timeline.entries, 0); - assert.equal(entry.pendingAnnotations.get("👋"), -1); + assert.equal(entry.pendingAnnotations.get("👋").count, -1); }, "decrypted entry preserves content when receiving other update without decryption": async assert => { // 1. create encrypted and decrypted entry diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 45874dcb..9302e009 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -17,7 +17,7 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; -import {PendingAnnotations} from "../PendingAnnotations.js"; +import {PendingAnnotation} from "../PendingAnnotation.js"; /** Deals mainly with local echo for relations and redactions, * so it is shared between PendingEventEntry and EventEntry */ @@ -65,10 +65,18 @@ export class BaseEventEntry extends BaseEntry { if (relationEntry.isRelatedToId(this.id)) { if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) { if (!this._pendingAnnotations) { - this._pendingAnnotations = new PendingAnnotations(); + this._pendingAnnotations = new Map(); + } + const {key} = (entry.redactingEntry || entry).relation; + if (key) { + let annotation = this._pendingAnnotations.get(key); + if (!annotation) { + annotation = new PendingAnnotation(); + this._pendingAnnotations.set(key, annotation); + } + annotation.add(entry); + return "pendingAnnotations"; } - this._pendingAnnotations.add(entry); - return "pendingAnnotations"; } } } @@ -92,11 +100,17 @@ export class BaseEventEntry extends BaseEntry { const relationEntry = entry.redactingEntry || entry; if (relationEntry.isRelatedToId(this.id)) { if (relationEntry.relation?.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { - this._pendingAnnotations.remove(entry); - if (this._pendingAnnotations.isEmpty) { - this._pendingAnnotations = null; + const {key} = (entry.redactingEntry || entry).relation; + if (key) { + let annotation = this._pendingAnnotations.get(key); + if (annotation.remove(entry) && annotation.isEmpty) { + this._pendingAnnotations.delete(key); + } + if (this._pendingAnnotations.size === 0) { + this._pendingAnnotations = null; + } + return "pendingAnnotations"; } - return "pendingAnnotations"; } } } @@ -128,19 +142,29 @@ export class BaseEventEntry extends BaseEntry { return id && this.relatedEventId === id; } + haveAnnotation(key) { + const haveRemoteReaction = this.annotations?.[key]?.me || false; + const pendingAnnotation = this.pendingAnnotations?.get(key); + const willAnnotate = pendingAnnotation?.willAnnotate || false; + /* + We have an annotation in these case: + - remote annotation with me, no pending + - remote annotation with me, pending redaction and then annotation + - pending annotation without redaction after it + */ + return (haveRemoteReaction && (!pendingAnnotation || willAnnotate)) || + (!haveRemoteReaction && willAnnotate); + } + get relation() { return getRelationFromContent(this.content); } get pendingAnnotations() { - return this._pendingAnnotations?.aggregatedAnnotations; + return this._pendingAnnotations; } - async getOwnAnnotationEntry(timeline, key) { - return this._pendingAnnotations?.findForKey(key); - } - - getAnnotationPendingRedaction(key) { - return this._pendingAnnotations?.findRedactionForKey(key); + get annotations() { + return null; //overwritten in EventEntry } } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index a0c3799d..f98801f9 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -139,13 +139,89 @@ export class EventEntry extends BaseEventEntry { get annotations() { return this._eventEntry.annotations; } +} - async getOwnAnnotationEntry(timeline, key) { - const localId = await super.getOwnAnnotationEntry(timeline, key); - if (localId) { - return localId; - } else { - return timeline.getOwnAnnotationEntry(this.id, key); +import {withTextBody, withContent, createEvent} from "../../../../mocks/event.js"; +import {Clock as MockClock} from "../../../../mocks/Clock.js"; +import {PendingEventEntry} from "./PendingEventEntry.js"; +import {PendingEvent} from "../../sending/PendingEvent.js"; +import {createAnnotation} from "../relations.js"; + +export function tests() { + let queueIndex = 0; + const clock = new MockClock(); + + function addPendingReaction(target, key) { + queueIndex += 1; + target.addLocalRelation(new PendingEventEntry({ + pendingEvent: new PendingEvent({data: { + eventType: "m.reaction", + content: createAnnotation(target.id, key), + queueIndex, + txnId: `t${queueIndex}` + }}), + clock + })); + return target; + } + + function addPendingRedaction(target, key) { + const pendingReaction = target.pendingAnnotations?.get(key)?.annotationEntry; + let redactingEntry = pendingReaction; + // make up a remote entry if we don't have a pending reaction and have an aggregated remote entry + if (!pendingReaction && target.annotations[key].me) { + redactingEntry = new EventEntry({ + event: withContent(createAnnotation(target.id, key), createEvent("m.reaction", "!def")) + }); + } + queueIndex += 1; + target.addLocalRelation(new PendingEventEntry({ + pendingEvent: new PendingEvent({data: { + eventType: "m.room.redaction", + relatedTxnId: pendingReaction ? pendingReaction.id : null, + relatedEventId: pendingReaction ? null : redactingEntry.id, + queueIndex, + txnId: `t${queueIndex}` + }}), + redactingEntry, + clock + })); + return target; + } + + function remoteAnnotation(key, me, count, obj = {}) { + obj[key] = {me, count}; + return obj; + } + + return { + // testing it here because parent class always assumes annotations is null + "haveAnnotation": assert => { + const msgEvent = withTextBody("hi!", createEvent("m.room.message", "!abc")); + const e1 = new EventEntry({event: msgEvent}); + assert.equal(false, e1.haveAnnotation("🚀")); + const e2 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", false, 1)}); + assert.equal(false, e2.haveAnnotation("🚀")); + const e3 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}); + assert.equal(true, e3.haveAnnotation("🚀")); + const e4 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 2)}); + assert.equal(true, e4.haveAnnotation("🚀")); + const e5 = addPendingReaction(new EventEntry({event: msgEvent}), "🚀"); + assert.equal(true, e5.haveAnnotation("🚀")); + const e6 = addPendingRedaction(new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}), "🚀"); + assert.equal(false, e6.haveAnnotation("🚀")); + const e7 = addPendingReaction( + addPendingRedaction( + new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}), + "🚀"), + "🚀"); + assert.equal(true, e7.haveAnnotation("🚀")); + const e8 = addPendingRedaction( + addPendingReaction( + new EventEntry({event: msgEvent}), + "🚀"), + "🚀"); + assert.equal(false, e8.haveAnnotation("🚀")); } } } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index d42211ef..742bff49 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -23,7 +23,10 @@ export class PendingEventEntry extends BaseEventEntry { this._pendingEvent = pendingEvent; /** @type {RoomMember} */ this._member = member; - this._clock = clock; + // try to come up with a timestamp that is around construction time and + // will be roughly sorted by queueIndex, so it can be used to as a secondary + // sorting dimension for reactions + this._timestamp = clock.now() - (100 - pendingEvent.queueIndex); this._redactingEntry = redactingEntry; } @@ -64,7 +67,7 @@ export class PendingEventEntry extends BaseEventEntry { } get timestamp() { - return this._clock.now(); + return this._timestamp; } get isPending() { From 061f44f475e07970255bd8c7296d90b44a3baea9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 12:56:23 +0200 Subject: [PATCH 180/213] extract methods here --- .../room/timeline/entries/BaseEventEntry.js | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 9302e009..2e681104 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -64,17 +64,7 @@ export class BaseEventEntry extends BaseEntry { const relationEntry = entry.redactingEntry || entry; if (relationEntry.isRelatedToId(this.id)) { if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) { - if (!this._pendingAnnotations) { - this._pendingAnnotations = new Map(); - } - const {key} = (entry.redactingEntry || entry).relation; - if (key) { - let annotation = this._pendingAnnotations.get(key); - if (!annotation) { - annotation = new PendingAnnotation(); - this._pendingAnnotations.set(key, annotation); - } - annotation.add(entry); + if (this._addPendingAnnotation(entry)) { return "pendingAnnotations"; } } @@ -100,15 +90,7 @@ export class BaseEventEntry extends BaseEntry { const relationEntry = entry.redactingEntry || entry; if (relationEntry.isRelatedToId(this.id)) { if (relationEntry.relation?.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) { - const {key} = (entry.redactingEntry || entry).relation; - if (key) { - let annotation = this._pendingAnnotations.get(key); - if (annotation.remove(entry) && annotation.isEmpty) { - this._pendingAnnotations.delete(key); - } - if (this._pendingAnnotations.size === 0) { - this._pendingAnnotations = null; - } + if (this._removePendingAnnotation(entry)) { return "pendingAnnotations"; } } @@ -116,6 +98,38 @@ export class BaseEventEntry extends BaseEntry { } } + _addPendingAnnotation(entry) { + if (!this._pendingAnnotations) { + this._pendingAnnotations = new Map(); + } + const {key} = (entry.redactingEntry || entry).relation; + if (key) { + let annotation = this._pendingAnnotations.get(key); + if (!annotation) { + annotation = new PendingAnnotation(); + this._pendingAnnotations.set(key, annotation); + } + annotation.add(entry); + return true; + } + return false; + } + + _removePendingAnnotation(entry) { + const {key} = (entry.redactingEntry || entry).relation; + if (key) { + let annotation = this._pendingAnnotations.get(key); + if (annotation.remove(entry) && annotation.isEmpty) { + this._pendingAnnotations.delete(key); + } + if (this._pendingAnnotations.size === 0) { + this._pendingAnnotations = null; + } + return true; + } + return false; + } + async abortPendingRedaction() { if (this._pendingRedactions) { for (const pee of this._pendingRedactions) { From c46c330efb93b550d0907123dcec1851f9bb83e5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:14:54 +0200 Subject: [PATCH 181/213] prevent duplicate redactions from distorting reaction local echo --- src/matrix/room/sending/SendQueue.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 90d6a988..8bc40d61 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -226,6 +226,14 @@ export class SendQueue { } async enqueueRedaction(eventIdOrTxnId, reason, log) { + const existingRedaction = this._pendingEvents.array.find(pe => { + return pe.eventType === REDACTION_TYPE && + (pe.relatedTxnId === eventIdOrTxnId || pe.relatedEventId === eventIdOrTxnId); + }); + if (existingRedaction) { + log.set("already_redacting", true); + return; + } let relatedTxnId; let relatedEventId; if (isTxnId(eventIdOrTxnId)) { @@ -393,6 +401,18 @@ export function tests() { assert.equal(index, 1); assert.equal(txnId, value.txnId); } + }, + "duplicate redaction gets dropped": async assert => { + const queue = new SendQueue({ + roomId: "!abc", + storage: await createMockStorage(), + hsApi: new MockHomeServer().api + }); + assert.equal(queue.pendingEvents.length, 0); + await queue.enqueueRedaction("!event", null, new NullLogItem()); + assert.equal(queue.pendingEvents.length, 1); + await queue.enqueueRedaction("!event", null, new NullLogItem()); + assert.equal(queue.pendingEvents.length, 1); } } } From 38b465cb9d1e4b50617bfa93e50ba5ba45fdf19b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:15:20 +0200 Subject: [PATCH 182/213] rename vm.toggleReaction to vm.toggle --- .../session/room/timeline/ReactionsViewModel.js | 12 ++++++------ .../web/ui/session/room/timeline/ReactionsView.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 886230a5..12413350 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -164,7 +164,7 @@ class ReactionViewModel { } } - toggleReaction(log = null) { + toggle(log = null) { return this._parentTile.toggleReaction(this.key, log); } } @@ -264,7 +264,7 @@ export function tests() { // make sure the preexisting reaction is counted assert.equal(reactionVM.count, 1); // 5.1. unset reaction, should redact the pre-existing reaction - await reactionVM.toggleReaction(); + await reactionVM.toggle(); { assert.equal(reactionVM.count, 0); const {value: redaction, type} = await queueObserver.next(); @@ -275,7 +275,7 @@ export function tests() { assert.equal("update", (await queueObserver.next()).type); } // 5.2. set reaction, should send a new reaction as the redaction is already sending - await reactionVM.toggleReaction(); + await reactionVM.toggle(); let reactionIndex; { assert.equal(reactionVM.count, 1); @@ -286,7 +286,7 @@ export function tests() { reactionIndex = index; } // 5.3. unset reaction, should abort the previous pending reaction as it hasn't started sending yet - await reactionVM.toggleReaction(); + await reactionVM.toggle(); { assert.equal(reactionVM.count, 0); const {index, type} = await queueObserver.next(); @@ -338,7 +338,7 @@ export function tests() { } // 5.2. unset reaction, should redact the previous pending reaction as it has started sending already let redactionIndex; - await reactionVM.toggleReaction(); + await reactionVM.toggle(); { assert.equal(reactionVM.count, 0); const {value: redaction, type, index} = await queueObserver.next(); @@ -348,7 +348,7 @@ export function tests() { redactionIndex = index; } // 5.3. set reaction, should abort the previous pending redaction as it hasn't started sending yet - await reactionVM.toggleReaction(); + await reactionVM.toggle(); { assert.equal(reactionVM.count, 1); const {index, type} = await queueObserver.next(); diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 0f243465..12f3b428 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -40,6 +40,6 @@ class ReactionView extends TemplateView { } onClick() { - this.value.toggleReaction(); + this.value.toggle(); } } From 668c0aff362747fa00f933ec302f13bdfb1eb3dc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:25:58 +0200 Subject: [PATCH 183/213] drop duplicate reactions in send queue, as last measure of defence --- src/matrix/room/sending/SendQueue.js | 41 +++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 8bc40d61..69034ee4 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -19,7 +19,7 @@ import {ConnectionError} from "../../error.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; import {REDACTION_TYPE} from "../common.js"; -import {getRelationFromContent, REACTION_TYPE} from "../timeline/relations.js"; +import {getRelationFromContent, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -205,9 +205,22 @@ export class SendQueue { async enqueueEvent(eventType, content, attachments, log) { const relation = getRelationFromContent(content); let relatedTxnId = null; - if (relation && isTxnId(relation.event_id)) { - relatedTxnId = relation.event_id; - relation.event_id = null; + if (relation) { + if (isTxnId(relation.event_id)) { + relatedTxnId = relation.event_id; + relation.event_id = null; + } + if (relation.rel_type === ANNOTATION_RELATION_TYPE) { + const isAlreadyAnnotating = this._pendingEvents.array.some(pe => { + const r = getRelationFromContent(pe.content); + return pe.eventType === eventType && r && r.key === relation.key && + (pe.relatedTxnId === relatedTxnId || r.event_id === relation.event_id); + }); + if (isAlreadyAnnotating) { + log.set("already_annotating", true); + return; + } + } } await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log); } @@ -226,11 +239,11 @@ export class SendQueue { } async enqueueRedaction(eventIdOrTxnId, reason, log) { - const existingRedaction = this._pendingEvents.array.find(pe => { + const isAlreadyRedacting = this._pendingEvents.array.some(pe => { return pe.eventType === REDACTION_TYPE && (pe.relatedTxnId === eventIdOrTxnId || pe.relatedEventId === eventIdOrTxnId); }); - if (existingRedaction) { + if (isAlreadyRedacting) { log.set("already_redacting", true); return; } @@ -340,6 +353,7 @@ import {ListObserver} from "../../../mocks/ListObserver.js"; import {NullLogger, NullLogItem} from "../../../logging/NullLogger.js"; import {createEvent, withTextBody, withTxnId} from "../../../mocks/event.js"; import {poll} from "../../../mocks/poll.js"; +import {createAnnotation} from "../timeline/relations.js"; export function tests() { const logger = new NullLogger(); @@ -413,6 +427,19 @@ export function tests() { assert.equal(queue.pendingEvents.length, 1); await queue.enqueueRedaction("!event", null, new NullLogItem()); assert.equal(queue.pendingEvents.length, 1); - } + }, + "duplicate reaction gets dropped": async assert => { + const queue = new SendQueue({ + roomId: "!abc", + storage: await createMockStorage(), + hsApi: new MockHomeServer().api + }); + assert.equal(queue.pendingEvents.length, 0); + await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem()); + assert.equal(queue.pendingEvents.length, 1); + await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem()); + assert.equal(queue.pendingEvents.length, 1); + }, + } } From 7557e2f43758e3ee8ac63c9ab8e481ce79d5c611 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:26:14 +0200 Subject: [PATCH 184/213] not used --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 6e43b138..d2f13692 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -157,7 +157,6 @@ export class BaseMessageTile extends SimpleTile { redactReaction(key, log = null) { return this.logger.wrapOrRun(log, "redactReaction", log => { - const keyVM = this.reactions?.getReaction(key); if (!this._entry.haveAnnotation(key)) { log.set("not_yet_reacted", true); return; From b148368d5b8ba40f03dac15c23224cc2a8cc037e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:29:13 +0200 Subject: [PATCH 185/213] test different keys do work still --- src/matrix/room/sending/SendQueue.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 69034ee4..d6b16ac1 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -437,8 +437,10 @@ export function tests() { assert.equal(queue.pendingEvents.length, 0); await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem()); assert.equal(queue.pendingEvents.length, 1); + await queue.enqueueEvent("m.reaction", createAnnotation("!target", "👋"), null, new NullLogItem()); + assert.equal(queue.pendingEvents.length, 2); await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem()); - assert.equal(queue.pendingEvents.length, 1); + assert.equal(queue.pendingEvents.length, 2); }, } From 366d3761b8bbc01ef511ac3970d786cfe3199c53 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 13:35:59 +0200 Subject: [PATCH 186/213] remove waiting for update event (it might not come in case of dupe) also remove duplicate logging impl for re(d)action at cost of double haveAnnotation call --- .../room/timeline/tiles/BaseMessageTile.js | 76 ++++--------------- 1 file changed, 15 insertions(+), 61 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index d2f13692..ba0198fe 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -27,7 +27,6 @@ export class BaseMessageTile extends SimpleTile { if (this._entry.annotations || this._entry.pendingAnnotations) { this._updateReactions(); } - this._pendingReactionChangeCallback = null; } get _mediaRepository() { @@ -124,79 +123,45 @@ export class BaseMessageTile extends SimpleTile { } react(key, log = null) { - return this.logger.wrapOrRun(log, "react", log => { + return this.logger.wrapOrRun(log, "react", async log => { if (this._entry.haveAnnotation(key)) { log.set("already_reacted", true); return; } - return this._react(key, log); - }); - } - - async _react(key, log) { - // This will also block concurently adding multiple reactions, - // but in practice it happens fast enough. - if (this._pendingReactionChangeCallback) { - log.set("ongoing", true); - return; - } - const redaction = this._entry.pendingAnnotations?.get(key)?.redactionEntry; - try { - const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); + const redaction = this._entry.pendingAnnotations?.get(key)?.redactionEntry; if (redaction && !redaction.pendingEvent.hasStartedSending) { log.set("abort_redaction", true); await redaction.pendingEvent.abort(); } else { await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log); } - await updatePromise; - } finally { - this._pendingReactionChangeCallback = null; - } + }); } redactReaction(key, log = null) { - return this.logger.wrapOrRun(log, "redactReaction", log => { + return this.logger.wrapOrRun(log, "redactReaction", async log => { if (!this._entry.haveAnnotation(key)) { log.set("not_yet_reacted", true); return; } - return this._redactReaction(key, log); - }); - } - - async _redactReaction(key, log) { - // This will also block concurently removing multiple reactions, - // but in practice it happens fast enough. - - // TODO: remove this as we'll protect against reentry in the SendQueue - if (this._pendingReactionChangeCallback) { - log.set("ongoing", true); - return; - } - let entry = this._entry.pendingAnnotations?.get(key)?.annotationEntry; - if (!entry) { - entry = await this._timeline.getOwnAnnotationEntry(this._entry.id, key); - } - if (entry) { - try { - const updatePromise = new Promise(resolve => this._pendingReactionChangeCallback = resolve); - await this._room.sendRedaction(entry.id, null, log); - await updatePromise; - } finally { - this._pendingReactionChangeCallback = null; + let entry = this._entry.pendingAnnotations?.get(key)?.annotationEntry; + if (!entry) { + entry = await this._timeline.getOwnAnnotationEntry(this._entry.id, key); } - } else { - log.set("no_reaction", true); - } + if (entry) { + await this._room.sendRedaction(entry.id, null, log); + } else { + log.set("no_reaction", true); + } + }); } toggleReaction(key, log = null) { return this.logger.wrapOrRun(log, "toggleReaction", async log => { if (this._entry.haveAnnotation(key)) { - await log.wrap("redactReaction", log => this._redactReaction(key, log)); + await this.redactReaction(key, log); } else { - await log.wrap("react", log => this._react(key, log)); + await this.react(key, log); } }); } @@ -206,23 +171,12 @@ export class BaseMessageTile extends SimpleTile { if (!annotations && !pendingAnnotations) { if (this._reactions) { this._reactions = null; - // The update comes in async because pending events are mapped in the timeline - // to pending event entries using an AsyncMappedMap, because in rare cases, the target - // of a redaction needs to be loaded from storage in order to know for which message - // the reaction needs to be removed. The SendQueue also only adds pending events after - // storing them first. - // This makes that if we want to know the local echo for either react or redactReaction is available, - // we need to async wait for the update call. In theory the update can also be triggered - // by something else than the reaction local echo changing (e.g. from sync), - // but this is very unlikely and deemed good enough for now. - this._pendingReactionChangeCallback && this._pendingReactionChangeCallback(); } } else { if (!this._reactions) { this._reactions = new ReactionsViewModel(this); } this._reactions.update(annotations, pendingAnnotations); - this._pendingReactionChangeCallback && this._pendingReactionChangeCallback(); } } } From 20ae21ead5d1a16764bb1aa9a80697428d3e0489 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:12:41 +0200 Subject: [PATCH 187/213] add some more emoji fonts that might be install by default --- src/platform/web/ui/css/font.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/web/ui/css/font.css b/src/platform/web/ui/css/font.css index f6ef9a29..9cc44cfe 100644 --- a/src/platform/web/ui/css/font.css +++ b/src/platform/web/ui/css/font.css @@ -6,6 +6,8 @@ local('Segoe UI Emoji'), local('Segoe UI Symbol'), local('Noto Color Emoji'), + local('Twemoji'), + local('Twemoji Mozilla'), local('Android Emoji'), local('EmojiSymbols'), local('Symbola'); From 3fa0f234bb6e101c6ea9016d35b4fc39ce053da3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:12:55 +0200 Subject: [PATCH 188/213] not used --- src/domain/session/room/timeline/ReactionsViewModel.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 12413350..50a25529 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -128,13 +128,6 @@ class ReactionViewModel { return this._annotation?.me || this.isPending; } - /** @returns {boolean} Whether the user has reacted with this key, - * taking the local reaction and reaction redaction into account. */ - get haveReacted() { - // TODO: cleanup - return this._parentTile._entry.haveAnnotation(this.key); - } - get firstTimestamp() { let ts = Number.MAX_SAFE_INTEGER; if (this._annotation) { From 299294daffd366631bafefb4915c51c383e38aab Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:24:22 +0200 Subject: [PATCH 189/213] prevent re(d)action in left/kicked room --- .../session/room/timeline/tiles/BaseMessageTile.js | 8 ++++++++ src/matrix/room/timeline/PowerLevels.js | 8 ++++++-- src/matrix/room/timeline/Timeline.js | 12 +++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index ba0198fe..897804bb 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -124,6 +124,10 @@ export class BaseMessageTile extends SimpleTile { react(key, log = null) { return this.logger.wrapOrRun(log, "react", async log => { + if (!this.canReact) { + log.set("powerlevel_lacking", true); + return; + } if (this._entry.haveAnnotation(key)) { log.set("already_reacted", true); return; @@ -140,6 +144,10 @@ export class BaseMessageTile extends SimpleTile { redactReaction(key, log = null) { return this.logger.wrapOrRun(log, "redactReaction", async log => { + if (!this._powerLevels.canRedactFromSender(this._ownMember.userId)) { + log.set("powerlevel_lacking", true); + return; + } if (!this._entry.haveAnnotation(key)) { log.set("not_yet_reacted", true); return; diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index f2315c38..26e5db1d 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -15,14 +15,15 @@ limitations under the License. */ export class PowerLevels { - constructor({powerLevelEvent, createEvent, ownUserId}) { + constructor({powerLevelEvent, createEvent, ownUserId, membership}) { this._plEvent = powerLevelEvent; this._createEvent = createEvent; this._ownUserId = ownUserId; + this._membership = membership; } canRedactFromSender(userId) { - if (userId === this._ownUserId) { + if (userId === this._ownUserId && this._membership === "join") { return true; } else { return this.canRedact; @@ -38,6 +39,9 @@ export class PowerLevels { } get _myLevel() { + if (this._membership !== "join") { + return Number.MIN_SAFE_INTEGER; + } return this._getUserLevel(this._ownUserId); } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 8fd84075..3d82a284 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -66,7 +66,7 @@ export class Timeline { // as they should only populate once the view subscribes to it // if they are populated already, the sender profile would be empty - this._powerLevels = await this._loadPowerLevels(txn); + this._powerLevels = await this._loadPowerLevels(membership, txn); // 30 seems to be a good amount to fill the entire screen const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); try { @@ -78,23 +78,25 @@ export class Timeline { // txn should be assumed to have finished here, as decryption will close it. } - async _loadPowerLevels(txn) { + async _loadPowerLevels(membership, txn) { // TODO: update power levels as state is updated const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); if (powerLevelsState) { return new PowerLevels({ powerLevelEvent: powerLevelsState.event, - ownUserId: this._ownMember.userId + ownUserId: this._ownMember.userId, + membership }); } const createState = await txn.roomState.get(this._roomId, "m.room.create", ""); if (createState) { return new PowerLevels({ createEvent: createState.event, - ownUserId: this._ownMember.userId + ownUserId: this._ownMember.userId, + membership }); } else { - return new PowerLevels({ownUserId: this._ownMember.userId}); + return new PowerLevels({ownUserId: this._ownMember.userId, membership}); } } From 575f3fa9668d15cbdbcf69c554d9d4095f73109f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:28:10 +0200 Subject: [PATCH 190/213] fix tests --- src/matrix/room/timeline/PowerLevels.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index 26e5db1d..d2fb026d 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -114,39 +114,45 @@ export function tests() { return { "redact somebody else event with power level event": assert => { - const pl1 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: alice}); + const pl1 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: alice, membership: "join"}); assert.equal(pl1.canRedact, true); - const pl2 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: bob}); + const pl2 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: bob, membership: "join"}); assert.equal(pl2.canRedact, false); }, "redact somebody else event with create event": assert => { - const pl1 = new PowerLevels({createEvent, ownUserId: alice}); + const pl1 = new PowerLevels({createEvent, ownUserId: alice, membership: "join"}); assert.equal(pl1.canRedact, true); - const pl2 = new PowerLevels({createEvent, ownUserId: bob}); + const pl2 = new PowerLevels({createEvent, ownUserId: bob, membership: "join"}); assert.equal(pl2.canRedact, false); }, "redact own event": assert => { - const pl = new PowerLevels({ownUserId: alice}); + const pl = new PowerLevels({ownUserId: alice, membership: "join"}); assert.equal(pl.canRedactFromSender(alice), true); assert.equal(pl.canRedactFromSender(bob), false); }, "can send event without power levels": assert => { - const pl = new PowerLevels({createEvent, ownUserId: charly}); + const pl = new PowerLevels({createEvent, ownUserId: charly, membership: "join"}); assert.equal(pl.canSendType("m.room.message"), true); }, "can't send any event below events_default": assert => { - const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: charly}); + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: charly, membership: "join"}); assert.equal(pl.canSendType("m.foo"), false); }, "can't send event below events[type]": assert => { - const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: bob}); + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: bob, membership: "join"}); assert.equal(pl.canSendType("m.foo"), true); assert.equal(pl.canSendType("m.room.message"), false); }, "can send event above or at events[type]": assert => { - const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice}); + const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice, membership: "join"}); assert.equal(pl.canSendType("m.room.message"), true); assert.equal(pl.canSendType("m.room.topic"), true); }, + "can't redact or send in non-joined room'": assert => { + const pl = new PowerLevels({createEvent, ownUserId: alice, membership: "leave"}); + assert.equal(pl.canRedact, false); + assert.equal(pl.canRedactFromSender(alice), false); + assert.equal(pl.canSendType("m.room.message"), false); + }, } } From 787308375cc4825a298fee240b6bc60e60792bf1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:33:16 +0200 Subject: [PATCH 191/213] prevent toggling in vm while already busy otherwise the check in SendQueue to prevent duplicates might fail --- .../session/room/timeline/ReactionsViewModel.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 50a25529..8813512d 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -157,8 +157,17 @@ class ReactionViewModel { } } - toggle(log = null) { - return this._parentTile.toggleReaction(this.key, log); + async toggle(log = null) { + if (this._isToggling) { + console.log("busy toggling reaction already"); + return; + } + this._isToggling = true; + try { + await this._parentTile.toggleReaction(this.key, log); + } finally { + this._isToggling = false; + } } } From 5984e8dd6d3e1b4b61a796f9043b0a0fe58982b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 14:49:43 +0200 Subject: [PATCH 192/213] don't show reactions for redacted messages --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 897804bb..8fe6f792 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -115,7 +115,9 @@ export class BaseMessageTile extends SimpleTile { } get reactions() { - return this._reactions; + if (this.shape !== "redacted") { + return this._reactions; + } } get canReact() { From bb6417dab974cb23da097ac0a786b42b246ebad0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 15:24:53 +0200 Subject: [PATCH 193/213] fix lint --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 8fe6f792..71c709b8 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -118,6 +118,7 @@ export class BaseMessageTile extends SimpleTile { if (this.shape !== "redacted") { return this._reactions; } + return null; } get canReact() { From eee1be1ceb5640d75288054c3730ebbdf7078a06 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 16:16:15 +0200 Subject: [PATCH 194/213] =?UTF-8?q?safari=20doesn't=20like=20empty=20strin?= =?UTF-8?q?g=20key=20paths=20=F0=9F=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/matrix/storage/idb/schema.js | 4 ++-- src/matrix/storage/idb/stores/TimelineRelationStore.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 256b6732..352c810c 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -140,5 +140,5 @@ async function migrateOperationScopeIndex(db, txn) { //v10 function createTimelineRelationsStore(db) { - db.createObjectStore("timelineRelations", {keyPath: ""}); -} \ No newline at end of file + db.createObjectStore("timelineRelations", {keyPath: "key"}); +} diff --git a/src/matrix/storage/idb/stores/TimelineRelationStore.js b/src/matrix/storage/idb/stores/TimelineRelationStore.js index 013fb2d6..bba24fc3 100644 --- a/src/matrix/storage/idb/stores/TimelineRelationStore.js +++ b/src/matrix/storage/idb/stores/TimelineRelationStore.js @@ -30,7 +30,7 @@ export class TimelineRelationStore { } add(roomId, targetEventId, relType, sourceEventId) { - return this._store.add(encodeKey(roomId, targetEventId, relType, sourceEventId)); + return this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)}); } remove(roomId, targetEventId, relType, sourceEventId) { @@ -56,8 +56,8 @@ export class TimelineRelationStore { true, true ); - const keys = await this._store.selectAll(range); - return keys.map(decodeKey); + const items = await this._store.selectAll(range); + return items.map(i => decodeKey(i.key)); } async getAllForTarget(roomId, targetId) { @@ -69,7 +69,7 @@ export class TimelineRelationStore { true, true ); - const keys = await this._store.selectAll(range); - return keys.map(decodeKey); + const items = await this._store.selectAll(range); + return items.map(i => decodeKey(i.key)); } } From ce647e78ce39bc6800f340515cc1647b9e060ded Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 17:17:01 +0200 Subject: [PATCH 195/213] release v0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aeca01bd..a8b19b7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.57", + "version": "0.2.0", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 5f1346568de7ff814799ac43b1981e0ff3417b5f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 28 Jun 2021 23:18:07 +0530 Subject: [PATCH 196/213] Handle avatar error Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 8845f887..715a4890 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -44,6 +44,11 @@ export class AvatarView extends BaseUpdateView { return false; } + setAvatarError() { + this._avatarError = true; + this.update(this.value); + } + _avatarTitleChanged() { if (this.value.avatarTitle !== this._avatarTitle) { this._avatarTitle = this.value.avatarTitle; @@ -65,6 +70,8 @@ export class AvatarView extends BaseUpdateView { this._avatarLetterChanged(); this._avatarTitleChanged(); this._root = renderStaticAvatar(this.value, this._size); + const image = this._root.firstChild; + image?.addEventListener("error", () => this.setAvatarError()); // takes care of update being called when needed super.mount(options); return this._root; @@ -76,10 +83,10 @@ export class AvatarView extends BaseUpdateView { update(vm) { // important to always call _...changed for every prop - if (this._avatarUrlChanged()) { + if (this._avatarUrlChanged() || this._avatarError) { // avatarColorNumber won't change, it's based on room/user id const bgColorClass = `usercolor${vm.avatarColorNumber}`; - if (vm.avatarUrl(this._size)) { + if (vm.avatarUrl(this._size) && !this._avatarError) { this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); this._root.classList.remove(bgColorClass); } else { @@ -87,7 +94,7 @@ export class AvatarView extends BaseUpdateView { this._root.classList.add(bgColorClass); } } - const hasAvatar = !!vm.avatarUrl(this._size); + const hasAvatar = !!(vm.avatarUrl(this._size) && !vm._avatarError); if (this._avatarTitleChanged() && hasAvatar) { const img = this._root.firstChild; img.setAttribute("title", vm.avatarTitle); @@ -95,6 +102,7 @@ export class AvatarView extends BaseUpdateView { if (this._avatarLetterChanged() && !hasAvatar) { this._root.firstChild.textContent = vm.avatarLetter; } + if (this._avatarError) { this._avatarError = false;} } } @@ -104,7 +112,7 @@ export class AvatarView extends BaseUpdateView { * @return {Element} */ export function renderStaticAvatar(vm, size, extraClasses = undefined) { - const hasAvatar = !!vm.avatarUrl(size); + const hasAvatar = !!(vm.avatarUrl(size) && !vm.avatarError); let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, From 97854423c4efd74a296208b4a653ecc1c0fa424b Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Mon, 28 Jun 2021 11:44:27 -0700 Subject: [PATCH 197/213] Ensure DM rooms have the same color in timeline and left panel. --- src/domain/session/rightpanel/RoomDetailsViewModel.js | 2 +- src/domain/session/room/RoomViewModel.js | 2 +- src/matrix/room/Room.js | 4 ++++ src/matrix/room/members/Heroes.js | 9 +++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/domain/session/rightpanel/RoomDetailsViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js index 911e5945..b9f05835 100644 --- a/src/domain/session/rightpanel/RoomDetailsViewModel.js +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -34,7 +34,7 @@ export class RoomDetailsViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this.roomId) + return getIdentifierColorNumber(this._room.avatarColorId) } avatarUrl(size) { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 4d53ec6c..38835db3 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -119,7 +119,7 @@ export class RoomViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._room.id) + return getIdentifierColorNumber(this._room.avatarColorId) } avatarUrl(size) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 482d167f..5e002411 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -328,6 +328,10 @@ export class Room extends BaseRoom { }); } + get avatarColorId() { + return this._heroes?.roomAvatarColorId || this._roomId + } + get isUnread() { return this._summary.data.isUnread; } diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index f6ad3085..6336cda2 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -97,4 +97,13 @@ export class Heroes { } return null; } + + get roomAvatarColorId() { + if (this._members.size === 1) { + for (const member of this._members.values()) { + return member.userId; + } + } + return null; + } } From 6527a0c677920acb17c827dd5ea7a6264602b7f1 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Mon, 28 Jun 2021 11:54:49 -0700 Subject: [PATCH 198/213] Make the room color match up on the left panel, too. --- src/domain/session/leftpanel/RoomTileViewModel.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index eebea618..e901e17d 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -15,6 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {getIdentifierColorNumber} from "../../avatar.js"; import {BaseTileViewModel} from "./BaseTileViewModel.js"; export class RoomTileViewModel extends BaseTileViewModel { @@ -75,6 +76,10 @@ export class RoomTileViewModel extends BaseTileViewModel { return timeDiff; } + get avatarColorNumber() { + return getIdentifierColorNumber(this._room.avatarColorId); + } + get isUnread() { return this._room.isUnread; } From 8b6ff533e8ed8604619c7cdc8ef41952fb0c9f14 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 29 Jun 2021 15:38:58 +0530 Subject: [PATCH 199/213] Add and remove opposing event listeners Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 715a4890..0a865963 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -44,7 +44,7 @@ export class AvatarView extends BaseUpdateView { return false; } - setAvatarError() { + _setAvatarError() { this._avatarError = true; this.update(this.value); } @@ -57,6 +57,20 @@ export class AvatarView extends BaseUpdateView { return false; } + _addListenersToAvatar(image) { + const handleAvatarError = (e) => { + const image = e.target; + image.removeEventListener("load", removeErrorHandler); + this._setAvatarError(); + }; + const removeErrorHandler = (e) => { + const image = e.target; + image.removeEventListener("error", handleAvatarError); + }; + image?.addEventListener("error", handleAvatarError); + image?.addEventListener("load", removeErrorHandler); + } + _avatarLetterChanged() { if (this.value.avatarLetter !== this._avatarLetter) { this._avatarLetter = this.value.avatarLetter; @@ -71,7 +85,7 @@ export class AvatarView extends BaseUpdateView { this._avatarTitleChanged(); this._root = renderStaticAvatar(this.value, this._size); const image = this._root.firstChild; - image?.addEventListener("error", () => this.setAvatarError()); + this._addListenersToAvatar(image); // takes care of update being called when needed super.mount(options); return this._root; From b42f7e1a361727c9fc6682efcf9d1f5f9ce0614d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 29 Jun 2021 19:48:36 +0530 Subject: [PATCH 200/213] remove both handlers Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 0a865963..d01a026e 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -58,17 +58,14 @@ export class AvatarView extends BaseUpdateView { } _addListenersToAvatar(image) { - const handleAvatarError = (e) => { + const imageEventHandler = (e) => { + if(e.type === "error") { this._setAvatarError(); } const image = e.target; - image.removeEventListener("load", removeErrorHandler); - this._setAvatarError(); + image.removeEventListener("error", imageEventHandler); + image.removeEventListener("load", imageEventHandler); }; - const removeErrorHandler = (e) => { - const image = e.target; - image.removeEventListener("error", handleAvatarError); - }; - image?.addEventListener("error", handleAvatarError); - image?.addEventListener("load", removeErrorHandler); + image?.addEventListener("error", imageEventHandler); + image?.addEventListener("load", imageEventHandler); } _avatarLetterChanged() { From dec06831452dbd2ee169c3a0774fe04a8c1828ca Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 29 Jun 2021 16:50:42 -0700 Subject: [PATCH 201/213] Correctly color archived and invited rooms --- src/domain/session/room/InviteViewModel.js | 2 +- src/matrix/room/ArchivedRoom.js | 4 ++++ src/matrix/room/BaseRoom.js | 4 ++++ src/matrix/room/Invite.js | 6 ++++++ src/matrix/room/Room.js | 4 ---- src/matrix/room/RoomSummary.js | 9 +++++++++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index a9cc917f..81a08e44 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -56,7 +56,7 @@ export class InviteViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._invite.id) + return getIdentifierColorNumber(this._invite.avatarColorId) } avatarUrl(size) { diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index f975191e..97a92450 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -107,6 +107,10 @@ export class ArchivedRoom extends BaseRoom { this._emitUpdate(); } + get avatarColorId() { + return this._summary.data.avatarColorId; + } + get isKicked() { return this._kickDetails?.membership === "leave"; } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index cef443c1..7d8bf040 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -341,6 +341,10 @@ export class BaseRoom extends EventEmitter { return null; } + get avatarColorId() { + return this._heroes?.roomAvatarColorId || this._roomId + } + get lastMessageTimestamp() { return this._summary.data.lastMessageTimestamp; } diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index aa25b0c6..a0391a12 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -56,6 +56,10 @@ export class Invite extends EventEmitter { return this._inviteData.avatarUrl; } + get avatarColorId() { + return this._inviteData.avatarColorId; + } + get timestamp() { return this._inviteData.timestamp; } @@ -175,6 +179,7 @@ export class Invite extends EventEmitter { _createData(inviteState, myInvite, inviter, summaryData, heroes) { const name = heroes ? heroes.roomName : summaryData.name; const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; + const avatarColorId = heroes ? heroes.roomAvatarColorId : summaryData.avatarColorId; return { roomId: this.id, isEncrypted: !!summaryData.encryption, @@ -182,6 +187,7 @@ export class Invite extends EventEmitter { // type: name, avatarUrl, + avatarColorId, canonicalAlias: summaryData.canonicalAlias, timestamp: this._platform.clock.now(), joinRule: this._getJoinRule(inviteState), diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 5e002411..482d167f 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -328,10 +328,6 @@ export class Room extends BaseRoom { }); } - get avatarColorId() { - return this._heroes?.roomAvatarColorId || this._roomId - } - get isUnread() { return this._summary.data.isUnread; } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index d0c78659..a99869ac 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -239,6 +239,15 @@ export class SummaryData { return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; } + get avatarColorId() { + if (this.heroes && this.heroes.length === 1) { + for (const hero of this.heroes) { + return hero; + } + } + return this.roomId; + } + isNewJoin(oldData) { return this.membership === "join" && oldData.membership !== "join"; } From d0f70cbdf9f3d9f1e29a3048106602cbabd9475c Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 29 Jun 2021 18:01:08 -0700 Subject: [PATCH 202/213] Move avatar color ID computation into SummaryData --- src/matrix/room/ArchivedRoom.js | 4 ---- src/matrix/room/BaseRoom.js | 2 +- src/matrix/room/Invite.js | 3 +-- src/matrix/room/members/Heroes.js | 9 --------- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 97a92450..f975191e 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -107,10 +107,6 @@ export class ArchivedRoom extends BaseRoom { this._emitUpdate(); } - get avatarColorId() { - return this._summary.data.avatarColorId; - } - get isKicked() { return this._kickDetails?.membership === "leave"; } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 7d8bf040..70fe5663 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -342,7 +342,7 @@ export class BaseRoom extends EventEmitter { } get avatarColorId() { - return this._heroes?.roomAvatarColorId || this._roomId + return this._summary.data.avatarColorId; } get lastMessageTimestamp() { diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index a0391a12..7346298b 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -179,7 +179,6 @@ export class Invite extends EventEmitter { _createData(inviteState, myInvite, inviter, summaryData, heroes) { const name = heroes ? heroes.roomName : summaryData.name; const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; - const avatarColorId = heroes ? heroes.roomAvatarColorId : summaryData.avatarColorId; return { roomId: this.id, isEncrypted: !!summaryData.encryption, @@ -187,7 +186,7 @@ export class Invite extends EventEmitter { // type: name, avatarUrl, - avatarColorId, + avatarColorId: summaryData.avatarColorId, canonicalAlias: summaryData.canonicalAlias, timestamp: this._platform.clock.now(), joinRule: this._getJoinRule(inviteState), diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index 6336cda2..f6ad3085 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -97,13 +97,4 @@ export class Heroes { } return null; } - - get roomAvatarColorId() { - if (this._members.size === 1) { - for (const member of this._members.values()) { - return member.userId; - } - } - return null; - } } From bcaf84e54587c77afab254124600209e93652b5d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 30 Jun 2021 23:27:46 +0530 Subject: [PATCH 203/213] Revert commits This reverts commit 5f1346568de7ff814799ac43b1981e0ff3417b5f. Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index d01a026e..8845f887 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -44,11 +44,6 @@ export class AvatarView extends BaseUpdateView { return false; } - _setAvatarError() { - this._avatarError = true; - this.update(this.value); - } - _avatarTitleChanged() { if (this.value.avatarTitle !== this._avatarTitle) { this._avatarTitle = this.value.avatarTitle; @@ -57,17 +52,6 @@ export class AvatarView extends BaseUpdateView { return false; } - _addListenersToAvatar(image) { - const imageEventHandler = (e) => { - if(e.type === "error") { this._setAvatarError(); } - const image = e.target; - image.removeEventListener("error", imageEventHandler); - image.removeEventListener("load", imageEventHandler); - }; - image?.addEventListener("error", imageEventHandler); - image?.addEventListener("load", imageEventHandler); - } - _avatarLetterChanged() { if (this.value.avatarLetter !== this._avatarLetter) { this._avatarLetter = this.value.avatarLetter; @@ -81,8 +65,6 @@ export class AvatarView extends BaseUpdateView { this._avatarLetterChanged(); this._avatarTitleChanged(); this._root = renderStaticAvatar(this.value, this._size); - const image = this._root.firstChild; - this._addListenersToAvatar(image); // takes care of update being called when needed super.mount(options); return this._root; @@ -94,10 +76,10 @@ export class AvatarView extends BaseUpdateView { update(vm) { // important to always call _...changed for every prop - if (this._avatarUrlChanged() || this._avatarError) { + if (this._avatarUrlChanged()) { // avatarColorNumber won't change, it's based on room/user id const bgColorClass = `usercolor${vm.avatarColorNumber}`; - if (vm.avatarUrl(this._size) && !this._avatarError) { + if (vm.avatarUrl(this._size)) { this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); this._root.classList.remove(bgColorClass); } else { @@ -105,7 +87,7 @@ export class AvatarView extends BaseUpdateView { this._root.classList.add(bgColorClass); } } - const hasAvatar = !!(vm.avatarUrl(this._size) && !vm._avatarError); + const hasAvatar = !!vm.avatarUrl(this._size); if (this._avatarTitleChanged() && hasAvatar) { const img = this._root.firstChild; img.setAttribute("title", vm.avatarTitle); @@ -113,7 +95,6 @@ export class AvatarView extends BaseUpdateView { if (this._avatarLetterChanged() && !hasAvatar) { this._root.firstChild.textContent = vm.avatarLetter; } - if (this._avatarError) { this._avatarError = false;} } } @@ -123,7 +104,7 @@ export class AvatarView extends BaseUpdateView { * @return {Element} */ export function renderStaticAvatar(vm, size, extraClasses = undefined) { - const hasAvatar = !!(vm.avatarUrl(size) && !vm.avatarError); + const hasAvatar = !!vm.avatarUrl(size); let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, From b469c4299f0ac60f7c70c4245f416738b25183bd Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 30 Jun 2021 23:30:44 +0530 Subject: [PATCH 204/213] implement new approach Signed-off-by: RMidhunSuresh --- src/platform/web/Platform.js | 2 ++ src/platform/web/ui/avatar.js | 20 ++++++++++++++++++-- src/platform/web/ui/css/avatar.css | 4 ++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index fea17ba1..3d89be9a 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -36,6 +36,7 @@ import {BlobHandle} from "./dom/BlobHandle.js"; import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandle.js"; import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables.js"; +import {handleAvatarError} from "./ui/avatar.js"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -189,6 +190,7 @@ export class Platform { this._disposables.track(disposable); } } + this._container.addEventListener("error", handleAvatarError, true); window.__hydrogenViewModel = vm; const view = new RootView(vm); this._container.appendChild(view.mount()); diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 8845f887..093cfd69 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -108,16 +108,32 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, - [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, + [`usercolor${vm.avatarColorNumber}`]: true, + ['has-image']: true }); if (extraClasses) { avatarClasses += ` ${extraClasses}`; } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); - return tag.div({className: avatarClasses}, [avatarContent]); + return tag.div({className: avatarClasses, 'data-avatar-letter': vm.avatarLetter}, [avatarContent]); } function renderImg(vm, size) { const sizeStr = size.toString(); return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle}); } + +function isAvatarEvent(e) { + const element = e.target; + const parent = element.parentElement; + return element.tagName === "IMG" && parent.classList.contains("avatar"); +} + +export function handleAvatarError(e) { + if (!isAvatarEvent(e)) { return; } + const parent = e.target.parentElement; + const avatarLetter = parent.getAttribute("data-avatar-letter"); + const letterNode = document.createTextNode(avatarLetter); + parent.appendChild(letterNode); + parent.classList.remove("has-image"); +} diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index d369f85f..85d370b7 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -31,6 +31,10 @@ limitations under the License. speak: none; } +.hydrogen .avatar:not(.has-image) img{ + display: none; +} + .hydrogen .avatar img { width: 100%; height: 100%; From 168b1d6247ab57e2f65b8f80aaaaf0bd0d511526 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 30 Jun 2021 23:34:23 +0530 Subject: [PATCH 205/213] Move AvatarView to separate file Signed-off-by: RMidhunSuresh --- src/platform/web/ui/AvatarView.js | 84 ++++++++++++++++++ src/platform/web/ui/avatar.js | 85 +------------------ .../web/ui/session/leftpanel/RoomTileView.js | 2 +- .../ui/session/rightpanel/RoomDetailsView.js | 2 +- src/platform/web/ui/session/room/RoomView.js | 2 +- 5 files changed, 88 insertions(+), 87 deletions(-) create mode 100644 src/platform/web/ui/AvatarView.js diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js new file mode 100644 index 00000000..22381838 --- /dev/null +++ b/src/platform/web/ui/AvatarView.js @@ -0,0 +1,84 @@ +import {BaseUpdateView} from "./general/BaseUpdateView.js"; +import {renderStaticAvatar, renderImg} from "./avatar.js"; +import {text} from "./general/html.js"; + +/* +optimization to not use a sub view when changing between img and text +because there can be many many instances of this view +*/ + +export class AvatarView extends BaseUpdateView { + /** + * @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} + * @param {Number} size + */ + constructor(value, size) { + super(value); + this._root = null; + this._avatarUrl = null; + this._avatarTitle = null; + this._avatarLetter = null; + this._size = size; + } + + _avatarUrlChanged() { + if (this.value.avatarUrl(this._size) !== this._avatarUrl) { + this._avatarUrl = this.value.avatarUrl(this._size); + return true; + } + return false; + } + + _avatarTitleChanged() { + if (this.value.avatarTitle !== this._avatarTitle) { + this._avatarTitle = this.value.avatarTitle; + return true; + } + return false; + } + + _avatarLetterChanged() { + if (this.value.avatarLetter !== this._avatarLetter) { + this._avatarLetter = this.value.avatarLetter; + return true; + } + return false; + } + + mount(options) { + this._avatarUrlChanged(); + this._avatarLetterChanged(); + this._avatarTitleChanged(); + this._root = renderStaticAvatar(this.value, this._size); + // takes care of update being called when needed + super.mount(options); + return this._root; + } + + root() { + return this._root; + } + + update(vm) { + // important to always call _...changed for every prop + if (this._avatarUrlChanged()) { + // avatarColorNumber won't change, it's based on room/user id + const bgColorClass = `usercolor${vm.avatarColorNumber}`; + if (vm.avatarUrl(this._size)) { + this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); + this._root.classList.remove(bgColorClass); + } else { + this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild); + this._root.classList.add(bgColorClass); + } + } + const hasAvatar = !!vm.avatarUrl(this._size); + if (this._avatarTitleChanged() && hasAvatar) { + const img = this._root.firstChild; + img.setAttribute("title", vm.avatarTitle); + } + if (this._avatarLetterChanged() && !hasAvatar) { + this._root.firstChild.textContent = vm.avatarLetter; + } + } +} diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 093cfd69..bda44db0 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -15,89 +15,6 @@ limitations under the License. */ import {tag, text, classNames} from "./general/html.js"; -import {BaseUpdateView} from "./general/BaseUpdateView.js"; - -/* -optimization to not use a sub view when changing between img and text -because there can be many many instances of this view -*/ - -export class AvatarView extends BaseUpdateView { - /** - * @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} - * @param {Number} size - */ - constructor(value, size) { - super(value); - this._root = null; - this._avatarUrl = null; - this._avatarTitle = null; - this._avatarLetter = null; - this._size = size; - } - - _avatarUrlChanged() { - if (this.value.avatarUrl(this._size) !== this._avatarUrl) { - this._avatarUrl = this.value.avatarUrl(this._size); - return true; - } - return false; - } - - _avatarTitleChanged() { - if (this.value.avatarTitle !== this._avatarTitle) { - this._avatarTitle = this.value.avatarTitle; - return true; - } - return false; - } - - _avatarLetterChanged() { - if (this.value.avatarLetter !== this._avatarLetter) { - this._avatarLetter = this.value.avatarLetter; - return true; - } - return false; - } - - mount(options) { - this._avatarUrlChanged(); - this._avatarLetterChanged(); - this._avatarTitleChanged(); - this._root = renderStaticAvatar(this.value, this._size); - // takes care of update being called when needed - super.mount(options); - return this._root; - } - - root() { - return this._root; - } - - update(vm) { - // important to always call _...changed for every prop - if (this._avatarUrlChanged()) { - // avatarColorNumber won't change, it's based on room/user id - const bgColorClass = `usercolor${vm.avatarColorNumber}`; - if (vm.avatarUrl(this._size)) { - this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); - this._root.classList.remove(bgColorClass); - } else { - this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild); - this._root.classList.add(bgColorClass); - } - } - const hasAvatar = !!vm.avatarUrl(this._size); - if (this._avatarTitleChanged() && hasAvatar) { - const img = this._root.firstChild; - img.setAttribute("title", vm.avatarTitle); - } - if (this._avatarLetterChanged() && !hasAvatar) { - this._root.firstChild.textContent = vm.avatarLetter; - } - } -} - /** * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Number} size @@ -118,7 +35,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { return tag.div({className: avatarClasses, 'data-avatar-letter': vm.avatarLetter}, [avatarContent]); } -function renderImg(vm, size) { +export function renderImg(vm, size) { const sizeStr = size.toString(); return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle}); } diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index 84b38b62..228addba 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomTileView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index a6d1a81f..8357b722 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -1,6 +1,6 @@ import {TemplateView} from "../../general/TemplateView.js"; import {classNames, tag} from "../../general/html.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomDetailsView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index f8e84f87..ccad448f 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -22,7 +22,7 @@ import {TimelineList} from "./TimelineList.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {RoomArchivedView} from "./RoomArchivedView.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomView extends TemplateView { constructor(options) { From 9ed6cd57f3a4c7a845746fc969f47a9bfbd92ae4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 1 Jul 2021 00:01:38 +0530 Subject: [PATCH 206/213] use textContent Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 7 ++----- src/platform/web/ui/css/avatar.css | 4 ---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index bda44db0..36d9b172 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -25,8 +25,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, - [`usercolor${vm.avatarColorNumber}`]: true, - ['has-image']: true + [`usercolor${vm.avatarColorNumber}`]: true }); if (extraClasses) { avatarClasses += ` ${extraClasses}`; @@ -50,7 +49,5 @@ export function handleAvatarError(e) { if (!isAvatarEvent(e)) { return; } const parent = e.target.parentElement; const avatarLetter = parent.getAttribute("data-avatar-letter"); - const letterNode = document.createTextNode(avatarLetter); - parent.appendChild(letterNode); - parent.classList.remove("has-image"); + parent.textContent = avatarLetter; } diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 85d370b7..d369f85f 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -31,10 +31,6 @@ limitations under the License. speak: none; } -.hydrogen .avatar:not(.has-image) img{ - display: none; -} - .hydrogen .avatar img { width: 100%; height: 100%; From ee1f1500e9617a0aebc15662a1b5225461eee1c7 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 30 Jun 2021 15:07:27 -0700 Subject: [PATCH 207/213] Roll back to using heroes for computing DM color --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- src/domain/session/leftpanel/RoomTileViewModel.js | 5 ----- src/matrix/room/BaseRoom.js | 8 +++++++- src/matrix/room/Invite.js | 3 ++- src/matrix/room/Room.js | 4 ++++ src/matrix/room/RoomSummary.js | 9 --------- src/matrix/room/members/Heroes.js | 9 +++++++++ 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 62ea6380..95e91458 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -69,7 +69,7 @@ export class BaseTileViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._avatarSource.id); + return getIdentifierColorNumber(this._avatarSource.avatarColorId); } avatarUrl(size) { diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index e901e17d..eebea618 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getIdentifierColorNumber} from "../../avatar.js"; import {BaseTileViewModel} from "./BaseTileViewModel.js"; export class RoomTileViewModel extends BaseTileViewModel { @@ -76,10 +75,6 @@ export class RoomTileViewModel extends BaseTileViewModel { return timeDiff; } - get avatarColorNumber() { - return getIdentifierColorNumber(this._room.avatarColorId); - } - get isUnread() { return this._room.isUnread; } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 70fe5663..9d33c5c5 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -341,8 +341,14 @@ export class BaseRoom extends EventEmitter { return null; } + /** + * Retrieve the identifier that should be used to color + * this room's avatar. By default this is the room's + * ID, but DM rooms should be the same color as their + * user's avatar. + */ get avatarColorId() { - return this._summary.data.avatarColorId; + return this._roomId; } get lastMessageTimestamp() { diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 7346298b..3e6a417c 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -179,6 +179,7 @@ export class Invite extends EventEmitter { _createData(inviteState, myInvite, inviter, summaryData, heroes) { const name = heroes ? heroes.roomName : summaryData.name; const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; + const avatarColorId = heroes ? heroes.roomAvatarColorId : this.id; return { roomId: this.id, isEncrypted: !!summaryData.encryption, @@ -186,7 +187,7 @@ export class Invite extends EventEmitter { // type: name, avatarUrl, - avatarColorId: summaryData.avatarColorId, + avatarColorId, canonicalAlias: summaryData.canonicalAlias, timestamp: this._platform.clock.now(), joinRule: this._getJoinRule(inviteState), diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 482d167f..a8e94326 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -328,6 +328,10 @@ export class Room extends BaseRoom { }); } + get avatarColorId() { + return this._heroes?.roomAvatarColorId || this._roomId; + } + get isUnread() { return this._summary.data.isUnread; } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index a99869ac..d0c78659 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -239,15 +239,6 @@ export class SummaryData { return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; } - get avatarColorId() { - if (this.heroes && this.heroes.length === 1) { - for (const hero of this.heroes) { - return hero; - } - } - return this.roomId; - } - isNewJoin(oldData) { return this.membership === "join" && oldData.membership !== "join"; } diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index f6ad3085..921eed47 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -97,4 +97,13 @@ export class Heroes { } return null; } + + get roomAvatarColorId() { + if (this._members.size === 1) { + for (const member of this._members.keys()) { + return member + } + } + return null; + } } From b40f946b85604b3ea57660ee125fc385f5cc421a Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 30 Jun 2021 15:15:08 -0700 Subject: [PATCH 208/213] Add JSDoc to new Hero method --- src/matrix/room/members/Heroes.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index 921eed47..29732def 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -98,6 +98,14 @@ export class Heroes { return null; } + /** + * In DM rooms, we want the room's color to be + * the same as the other user's color. Thus, if the room + * only has one hero, we use their ID, instead + * of the room's, to get the avatar color. + * + * @returns {?string} the ID of the single hero. + */ get roomAvatarColorId() { if (this._members.size === 1) { for (const member of this._members.keys()) { From f7d6569154899bc86961e3344e38fa994f26dd6e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 30 Jun 2021 15:21:11 -0700 Subject: [PATCH 209/213] Add a small comment to Invite.avatarColorId, too. --- src/matrix/room/Invite.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 3e6a417c..ce400f01 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -56,6 +56,7 @@ export class Invite extends EventEmitter { return this._inviteData.avatarUrl; } + /** @see BaseRoom.avatarColorId */ get avatarColorId() { return this._inviteData.avatarColorId; } From 03a913629f0ef44ad120a5abc3721322c7dc237e Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 1 Jul 2021 15:25:28 +0530 Subject: [PATCH 210/213] Pass color as data attribute Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 36d9b172..c1660e70 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -25,13 +25,13 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, - [`usercolor${vm.avatarColorNumber}`]: true + [`usercolor${vm.avatarColorNumber}`]: !hasAvatar }); if (extraClasses) { avatarClasses += ` ${extraClasses}`; } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); - return tag.div({className: avatarClasses, 'data-avatar-letter': vm.avatarLetter}, [avatarContent]); + return tag.div({className: avatarClasses, 'data-avatar-letter': vm.avatarLetter, 'data-avatar-color': vm.avatarColorNumber}, [avatarContent]); } export function renderImg(vm, size) { @@ -48,6 +48,8 @@ function isAvatarEvent(e) { export function handleAvatarError(e) { if (!isAvatarEvent(e)) { return; } const parent = e.target.parentElement; + const avatarColorNumber = parent.getAttribute("data-avatar-color"); + parent.classList.add(`usercolor${avatarColorNumber}`); const avatarLetter = parent.getAttribute("data-avatar-letter"); parent.textContent = avatarLetter; } From 93e77a3fcd313d360f0258201645c3c923a96c89 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 1 Jul 2021 15:41:40 +0530 Subject: [PATCH 211/213] Only add attribute if we have avatar Signed-off-by: RMidhunSuresh --- src/platform/web/ui/avatar.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index c1660e70..2e2b0142 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text, classNames} from "./general/html.js"; +import {tag, text, classNames, setAttribute} from "./general/html.js"; /** * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Number} size @@ -31,7 +31,12 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { avatarClasses += ` ${extraClasses}`; } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); - return tag.div({className: avatarClasses, 'data-avatar-letter': vm.avatarLetter, 'data-avatar-color': vm.avatarColorNumber}, [avatarContent]); + const avatar = tag.div({className: avatarClasses}, [avatarContent]); + if (hasAvatar) { + setAttribute(avatar, "data-avatar-letter", vm.avatarLetter); + setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber); + } + return avatar; } export function renderImg(vm, size) { From b8c01272f4ca3a679dd6e4084d78cf343a637442 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 1 Jul 2021 15:42:07 +0530 Subject: [PATCH 212/213] remove listener on dispose Signed-off-by: RMidhunSuresh --- src/platform/web/Platform.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 3d89be9a..49a90dd5 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -191,6 +191,7 @@ export class Platform { } } this._container.addEventListener("error", handleAvatarError, true); + this._disposables.track(() => this._container.removeEventListener("error", handleAvatarError, true)); window.__hydrogenViewModel = vm; const view = new RootView(vm); this._container.appendChild(view.mount()); From 191613adbecfcd89e667970ebd4c28435267a83c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 1 Jul 2021 19:21:54 +0530 Subject: [PATCH 213/213] Make changes - use textContent where possible - make sure we have an image before adding title Signed-off-by: RMidhunSuresh --- src/platform/web/ui/AvatarView.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js index 22381838..1f6f2736 100644 --- a/src/platform/web/ui/AvatarView.js +++ b/src/platform/web/ui/AvatarView.js @@ -68,17 +68,19 @@ export class AvatarView extends BaseUpdateView { this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); this._root.classList.remove(bgColorClass); } else { - this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild); + this._root.textContent = vm.avatarLetter; this._root.classList.add(bgColorClass); } } const hasAvatar = !!vm.avatarUrl(this._size); if (this._avatarTitleChanged() && hasAvatar) { - const img = this._root.firstChild; - img.setAttribute("title", vm.avatarTitle); + const element = this._root.firstChild; + if (element.tagName === "IMG") { + element.setAttribute("title", vm.avatarTitle); + } } if (this._avatarLetterChanged() && !hasAvatar) { - this._root.firstChild.textContent = vm.avatarLetter; + this._root.textContent = vm.avatarLetter; } } }