From 36a35d92f044a1b4b40f10803345bb2c47d3f935 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:42:09 +0200 Subject: [PATCH 01/90] 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 02/90] 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 03/90] 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 04/90] 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 05/90] 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 06/90] 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 07/90] 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 08/90] 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 09/90] 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 10/90] 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 11/90] 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 12/90] 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 13/90] 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 14/90] 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 15/90] 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 16/90] 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 17/90] 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 18/90] 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 19/90] 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 20/90] 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 21/90] 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 22/90] 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 23/90] 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 33655ee37e229c650842777319a3c2f46e6a665a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Jun 2021 16:32:03 +0200 Subject: [PATCH 24/90] 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 25/90] 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 26/90] 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 2ebadb36c3d80b53323f539aaab8efd5d69aefff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Jun 2021 13:20:55 +0200 Subject: [PATCH 27/90] 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 28/90] 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 cb051ad161aa2a781fb233c220d7a7d6554937ef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Jun 2021 16:52:30 +0200 Subject: [PATCH 29/90] 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 30/90] 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 31/90] 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 32/90] 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 33/90] 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 34/90] 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 e5c10941538b02a9e48f5a0998ea966a242f5575 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 15 Jun 2021 19:06:41 +0200 Subject: [PATCH 35/90] 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 36/90] 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 37/90] 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 38/90] 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 39/90] 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 40/90] 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 41/90] 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 42/90] 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 43/90] 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 44/90] 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 45/90] 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 46/90] 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 47/90] 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 48/90] 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 49/90] 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 50/90] 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 51/90] 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 52/90] 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 53/90] 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 54/90] 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 55/90] 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 56/90] 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 57/90] 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 58/90] 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 81f06f565ebcf2b7d84253e55fb5d5747a1fa66b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 21 Jun 2021 17:26:08 +0200 Subject: [PATCH 59/90] 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 60/90] 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 61/90] 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 62/90] 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 63/90] 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 64/90] 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 65/90] 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 66/90] 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 67/90] 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 68/90] 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 69/90] 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 70/90] 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 71/90] 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 72/90] 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 73/90] 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 74/90] 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 a4a7c231484caf4e9d585d1deb39dda76af503e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 24 Jun 2021 12:26:38 +0200 Subject: [PATCH 75/90] 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 76/90] 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 77/90] 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 78/90] 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 79/90] 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 80/90] 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 81/90] 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 82/90] 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 83/90] 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 84/90] 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 85/90] 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 86/90] 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 87/90] 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 88/90] 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 89/90] 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 90/90] =?UTF-8?q?safari=20doesn't=20like=20empty=20string?= =?UTF-8?q?=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)); } }