From edaac9f4364ca600d5b4fee96c3bb14428f03c30 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 May 2021 16:41:07 +0200 Subject: [PATCH 01/73] draft redaction support, no local echo yet --- src/matrix/common.js | 15 ++++++ src/matrix/net/HomeServerApi.js | 4 ++ src/matrix/room/sending/PendingEvent.js | 44 +++++++++++---- src/matrix/room/sending/SendQueue.js | 72 +++++++++++++++++++++---- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/matrix/common.js b/src/matrix/common.js index 3c893234..67a95205 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -19,4 +19,19 @@ export function makeTxnId() { const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); const str = n.toString(16); return "t" + "0".repeat(14 - str.length) + str; +} + +export function isTxnId(txnId) { + return txnId.startsWith("t") && txnId.length === 15; +} + +export function tests() { + return { + "isTxnId succeeds on result of makeTxnId": assert => { + assert(isTxnId(makeTxnId())); + }, + "isTxnId fails on event id": assert => { + assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); + }, + } } \ No newline at end of file diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index f834b94b..02d4dc2d 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -121,6 +121,10 @@ export class HomeServerApi { return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); } + redact(roomId, eventId, txnId, content, options = null) { + return this._put(`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, {}, content, options); + } + receipt(roomId, receiptType, eventId, options = null) { return this._post(`/rooms/${encodeURIComponent(roomId)}/receipt/${encodeURIComponent(receiptType)}/${encodeURIComponent(eventId)}`, {}, {}, options); diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index e6d518ca..f62cbc64 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,6 +15,8 @@ limitations under the License. */ import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; +import {isTxnId} from "../../common.js"; +import {REDACTION_TYPE} from "./SendQueue.js"; export const SendStatus = createEnum( "Waiting", @@ -134,7 +136,7 @@ export class PendingEvent { this._data.needsUpload = false; } - abort() { + async abort() { if (!this._aborted) { this._aborted = true; if (this._attachments) { @@ -143,7 +145,7 @@ export class PendingEvent { } } this._sendRequest?.abort(); - this._removeFromQueueCallback(); + await this._removeFromQueueCallback(); } } @@ -156,15 +158,27 @@ export class PendingEvent { this._emitUpdate("status"); const eventType = this._data.encryptedEventType || this._data.eventType; const content = this._data.encryptedContent || this._data.content; - this._sendRequest = hsApi.send( - this.roomId, - eventType, - this.txnId, - content, - {log} - ); + if (eventType === REDACTION_TYPE) { + // TODO: should we double check here that this._data.redacts is not a txnId here anymore? + this._sendRequest = hsApi.redact( + this.roomId, + this._data.redacts, + this.txnId, + content, + {log} + ); + } else { + this._sendRequest = hsApi.send( + this.roomId, + eventType, + this.txnId, + content, + {log} + ); + } const response = await this._sendRequest.response(); this._sendRequest = null; + // both /send and /redact have the same response format this._data.remoteId = response.event_id; log.set("id", this._data.remoteId); this._status = SendStatus.Sent; @@ -178,4 +192,16 @@ export class PendingEvent { } } } + + get relatedTxnId() { + if (isTxnId(this._data.redacts)) { + return this._data.redacts; + } + } + + setRelatedEventId(eventId) { + if (this._data.redacts) { + this._data.redacts = eventId; + } + } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 4ad5e527..1420b56e 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -17,7 +17,9 @@ limitations under the License. import {SortedArray} from "../../../observable/list/SortedArray.js"; import {ConnectionError} from "../../error.js"; import {PendingEvent} from "./PendingEvent.js"; -import {makeTxnId} from "../../common.js"; +import {makeTxnId, isTxnId} from "../../common.js"; + +export const REDACTION_TYPE = "m.room.redaction"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -101,8 +103,24 @@ export class SendQueue { } if (pendingEvent.needsSending) { await pendingEvent.send(this._hsApi, log); - - await this._tryUpdateEvent(pendingEvent); + // we now have a remoteId, but this pending event may be removed at any point in the future + // once the remote echo comes in. So if we have any related events that need to resolve + // the relatedTxnId to a related event id, they need to do so now. + // We ensure this by writing the new remote id for the pending event and all related events + // with unresolved relatedTxnId in the queue in one transaction. + const relatedEvents = this._pendingEvents.array.find(pe => pe.relatedTxnId === pendingEvent.txnId); + const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); + try { + await this._tryUpdateEventWithTxn(pendingEvent, txn); + for (const relatedPE of relatedEvents) { + relatedPE.setRelatedEventId(pendingEvent.remoteId); + await this._tryUpdateEventWithTxn(relatedPE, txn); + } + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); } } @@ -168,7 +186,12 @@ export class SendQueue { } async enqueueEvent(eventType, content, attachments, log) { - const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments); + await this._enqueueEvent(eventType, content, attachments, null, log); + } + + + async _enqueueEvent(eventType, content, attachments, redacts, log) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, redacts, attachments); this._pendingEvents.set(pendingEvent); log.set("queueIndex", pendingEvent.queueIndex); log.set("pendingEvents", this._pendingEvents.length); @@ -180,6 +203,27 @@ export class SendQueue { } } + async enqueueRedaction(eventIdOrTxnId, reason, log) { + if (isTxnId(eventIdOrTxnId)) { + const txnId = eventIdOrTxnId; + const pe = this._pendingEvents.array.find(pe => pe.txnId === txnId); + if (pe && !pe.remoteId && pe.status !== SendStatus.Sending) { + // haven't started sending this event yet, + // just remove it from the queue + await pe.abort(); + return; + } else if (!pe) { + // we don't have the pending event anymore, + // the remote echo must have arrived in the meantime. + // we could look for it in the timeline, but for now + // we don't do anything as this race is quite unlikely + // and a bit complicated to fix. + return; + } + } + await this._enqueueEvent(REDACTION_TYPE, {reason}, null, eventIdOrTxnId, log); + } + get pendingEvents() { return this._pendingEvents; } @@ -187,11 +231,7 @@ export class SendQueue { async _tryUpdateEvent(pendingEvent) { const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); try { - // pendingEvent might have been removed already here - // by a racing remote echo, so check first so we don't recreate it - if (await txn.pendingEvents.exists(pendingEvent.roomId, pendingEvent.queueIndex)) { - txn.pendingEvents.update(pendingEvent.data); - } + this._tryUpdateEventWithTxn(pendingEvent, txn); } catch (err) { txn.abort(); throw err; @@ -199,20 +239,30 @@ export class SendQueue { await txn.complete(); } - async _createAndStoreEvent(eventType, content, attachments) { + async _tryUpdateEventWithTxn(pendingEvent, txn) { + // pendingEvent might have been removed already here + // by a racing remote echo, so check first so we don't recreate it + if (await txn.pendingEvents.exists(pendingEvent.roomId, pendingEvent.queueIndex)) { + txn.pendingEvents.update(pendingEvent.data); + } + } + + async _createAndStoreEvent(eventType, content, redacts, attachments) { const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; try { const pendingEventsStore = txn.pendingEvents; const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0; const queueIndex = maxQueueIndex + 1; + const needsEncryption = eventType !== REDACTION_TYPE && !!this._roomEncryption; pendingEvent = this._createPendingEvent({ roomId: this._roomId, queueIndex, eventType, content, + redacts, txnId: makeTxnId(), - needsEncryption: !!this._roomEncryption, + needsEncryption, needsUpload: !!attachments }, attachments); pendingEventsStore.add(pendingEvent.data); From 9b923d337d3bfea6e817b30055c9e07582615906 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 10:01:30 +0200 Subject: [PATCH 02/73] write redactions during sync --- src/matrix/room/BaseRoom.js | 2 + src/matrix/room/Room.js | 11 ++- src/matrix/room/common.js | 2 + src/matrix/room/sending/PendingEvent.js | 2 +- src/matrix/room/sending/SendQueue.js | 3 +- .../room/timeline/entries/EventEntry.js | 6 +- .../room/timeline/persistence/GapWriter.js | 12 ++- .../timeline/persistence/RelationWriter.js | 99 +++++++++++++++++++ .../room/timeline/persistence/SyncWriter.js | 30 +++--- 9 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 src/matrix/room/timeline/persistence/RelationWriter.js diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 924e5316..e6e7ae33 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -288,6 +288,8 @@ export class BaseRoom extends EventEmitter { this._applyGapFill(extraGapFillChanges); } if (this._timeline) { + // these should not be added if not already there + this._timeline.replaceEntries(gapResult.updatedEntries); this._timeline.addOrReplaceEntries(gapResult.entries); } }); diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 57382831..c743dcb8 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -106,9 +106,8 @@ export class Room extends BaseRoom { txn.roomState.removeAllForRoom(this.id); txn.roomMembers.removeAllForRoom(this.id); } - const {entries: newEntries, newLiveKey, memberChanges} = + const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} = await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); - let allEntries = newEntries; if (decryptChanges) { const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); log.set("decryptionResults", decryption.results.size); @@ -119,16 +118,18 @@ export class Room extends BaseRoom { decryption.applyToEntries(newEntries); if (retryEntries?.length) { decryption.applyToEntries(retryEntries); - allEntries = retryEntries.concat(allEntries); + updatedEntries.push(...retryEntries); } } - log.set("allEntries", allEntries.length); + log.set("newEntries", newEntries.length); + log.set("updatedEntries", updatedEntries.length); let shouldFlushKeyShares = false; // pass member changes to device tracker if (roomEncryption && this.isTrackingMembers && memberChanges?.size) { shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log); log.set("shouldFlushKeyShares", shouldFlushKeyShares); } + const allEntries = newEntries.concat(updatedEntries); // also apply (decrypted) timeline entries to the summary changes summaryChanges = summaryChanges.applyTimelineEntries( allEntries, isInitialSync, !this._isTimelineOpen, this._user.id); @@ -164,7 +165,7 @@ export class Room extends BaseRoom { summaryChanges, roomEncryption, newEntries, - updatedEntries: retryEntries || [], + updatedEntries, newLiveKey, removedPendingEvents, memberChanges, diff --git a/src/matrix/room/common.js b/src/matrix/room/common.js index 922ca115..721160e6 100644 --- a/src/matrix/room/common.js +++ b/src/matrix/room/common.js @@ -19,3 +19,5 @@ export function getPrevContentFromStateEvent(event) { // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz return event.unsigned?.prev_content || event.prev_content; } + +export const REDACTION_TYPE = "m.room.redaction"; diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index f62cbc64..3d0c3fe1 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,8 +15,8 @@ limitations under the License. */ import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; +import {REDACTION_TYPE} from "../common.js"; import {isTxnId} from "../../common.js"; -import {REDACTION_TYPE} from "./SendQueue.js"; export const SendStatus = createEnum( "Waiting", diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 1420b56e..533a29b3 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -18,8 +18,7 @@ import {SortedArray} from "../../../observable/list/SortedArray.js"; import {ConnectionError} from "../../error.js"; import {PendingEvent} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; - -export const REDACTION_TYPE = "m.room.redaction"; +import {REDACTION_TYPE} from "../common.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 88a0aa5e..5f2496d8 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -108,4 +108,8 @@ export class EventEntry extends BaseEntry { get decryptionError() { return this._decryptionError; } -} + + get relatedEventId() { + return this._eventEntry.event.redacts; + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index ebd2bedf..860265f5 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -14,6 +14,7 @@ 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"; @@ -24,6 +25,7 @@ export class GapWriter { this._roomId = roomId; this._storage = storage; this._fragmentIdComparer = fragmentIdComparer; + this._relationWriter = new RelationWriter(roomId, fragmentIdComparer); } // events is in reverse-chronological order (last event comes at index 0) if backwards async _findOverlappingEvents(fragmentEntry, events, txn, log) { @@ -105,6 +107,7 @@ export class GapWriter { _storeEvents(events, startKey, direction, state, txn) { const entries = []; + const updatedEntries = []; // events is in reverse chronological order for backwards pagination, // e.g. order is moving away from the `from` point. let key = startKey; @@ -120,6 +123,10 @@ export class GapWriter { txn.timelineEvents.insert(eventStorageEntry); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); directionalAppend(entries, eventEntry, direction); + const updatedRelationTargetEntry = this._relationWriter.writeRelation(eventEntry); + if (updatedRelationTargetEntry) { + updatedEntries.push(updatedRelationTargetEntry); + } } return entries; } @@ -201,7 +208,6 @@ export class GapWriter { // chunk is in reverse-chronological order when backwards const {chunk, start, state} = response; let {end} = response; - let entries; if (!Array.isArray(chunk)) { throw new Error("Invalid chunk in response"); @@ -240,9 +246,9 @@ export class GapWriter { end = null; } // create entries for all events in chunk, add them to entries - entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); + const {entries, updatedEntries} = this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); const fragments = await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn); - return {entries, fragments}; + return {entries, updatedEntries, fragments}; } } diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js new file mode 100644 index 00000000..67a78bb3 --- /dev/null +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -0,0 +1,99 @@ +/* +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 {EventEntry} from "../entries/EventEntry.js"; +import {REDACTION_TYPE} from "../../common.js"; + +export class RelationWriter { + constructor(roomId, fragmentIdComparer) { + this._roomId = roomId; + this._fragmentIdComparer = fragmentIdComparer; + } + + // this needs to happen again after decryption too for edits + async writeRelation(sourceEntry, txn) { + if (sourceEntry.relatedEventId) { + const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); + if (target) { + if (this._applyRelation(sourceEntry, target)) { + txn.timelineEvents.update(target); + return new EventEntry(target, this._fragmentIdComparer); + } + } + } + return; + } + + _applyRelation(sourceEntry, target) { + if (sourceEntry.eventType === REDACTION_TYPE) { + return this._applyRedaction(sourceEntry.event, target.event); + } else { + return false; + } + } + + _applyRedaction(redactionEvent, targetEvent) { + // 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 {content} = targetEvent; + const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type]; + for (const key of Object.keys(content)) { + if (!keepMap?.[key]) { + delete content[key]; + } + } + targetEvent.unsigned = targetEvent.unsigned || {}; + targetEvent.unsigned.redacted_because = redactionEvent; + + 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 + * + * This is specified here: + * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions + * + * Also: + * - We keep 'unsigned' since that is created by the local server + * - We keep user_id for backwards-compat with v1 + */ +const _REDACT_KEEP_KEY_MAP = [ + 'event_id', 'type', 'room_id', 'user_id', 'sender', 'state_key', 'prev_state', + 'content', 'unsigned', 'origin_server_ts', +].reduce(function(ret, val) { + ret[val] = 1; return ret; +}, {}); + +// a map from event type to the .content keys we keep when an event is redacted +const _REDACT_KEEP_CONTENT_MAP = { + 'm.room.member': {'membership': 1}, + 'm.room.create': {'creator': 1}, + 'm.room.join_rules': {'join_rule': 1}, + 'm.room.power_levels': {'ban': 1, 'events': 1, 'events_default': 1, + 'kick': 1, 'redact': 1, 'state_default': 1, + 'users': 1, 'users_default': 1, + }, + 'm.room.aliases': {'aliases': 1}, +}; +// end of matrix-js-sdk code \ No newline at end of file diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 0aeec413..8bf31ed2 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -21,6 +21,7 @@ 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 @@ -40,6 +41,7 @@ export class SyncWriter { constructor({roomId, fragmentIdComparer}) { this._roomId = roomId; this._memberWriter = new MemberWriter(roomId); + this._relationWriter = new RelationWriter(roomId, fragmentIdComparer); this._fragmentIdComparer = fragmentIdComparer; this._lastLiveKey = null; } @@ -151,7 +153,9 @@ export class SyncWriter { } } - async _writeTimeline(entries, timeline, currentKey, memberChanges, txn, log) { + async _writeTimeline(timeline, currentKey, memberChanges, txn, log) { + const entries = []; + const updatedEntries = []; if (Array.isArray(timeline?.events) && timeline.events.length) { // only create a fragment when we will really write an event currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); @@ -161,15 +165,19 @@ export class SyncWriter { for(const event of events) { // store event in timeline currentKey = currentKey.nextKey(); - const entry = createEventEntry(currentKey, this._roomId, event); + const storageEntry = createEventEntry(currentKey, this._roomId, event); let member = await this._memberWriter.lookupMember(event.sender, event, events, txn); if (member) { - entry.displayName = member.displayName; - entry.avatarUrl = member.avatarUrl; + storageEntry.displayName = member.displayName; + storageEntry.avatarUrl = member.avatarUrl; + } + txn.timelineEvents.insert(storageEntry); + const entry = new EventEntry(storageEntry, this._fragmentIdComparer); + entries.push(entry); + const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry); + if (updatedRelationTargetEntry) { + updatedEntries.push(updatedRelationTargetEntry); } - txn.timelineEvents.insert(entry); - entries.push(new EventEntry(entry, this._fragmentIdComparer)); - // update state events after writing event, so for a member event, // we only update the member info after having written the member event // to the timeline, as we want that event to have the old profile info @@ -187,7 +195,7 @@ export class SyncWriter { } log.set("timelineStateEventCount", timelineStateEventCount); } - return currentKey; + return {currentKey, entries, updatedEntries}; } async _handleRejoinOverlap(timeline, txn, log) { @@ -226,7 +234,6 @@ export class SyncWriter { * @return {SyncWriterResult} */ async writeSync(roomResponse, isRejoin, txn, log) { - const entries = []; let {timeline} = roomResponse; // we have rejoined the room after having synced it before, // check for overlap with the last synced event @@ -238,9 +245,10 @@ export class SyncWriter { // important this happens before _writeTimeline so // members are available in the transaction await this._writeStateEvents(roomResponse, memberChanges, timeline?.limited, txn, log); - const currentKey = await this._writeTimeline(entries, timeline, this._lastLiveKey, memberChanges, txn, log); + const {currentKey, entries, updatedEntries} = + await this._writeTimeline(entries, updatedEntries, timeline, this._lastLiveKey, memberChanges, txn, log); log.set("memberChanges", memberChanges.size); - return {entries, newLiveKey: currentKey, memberChanges}; + return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; } afterSync(newLiveKey) { From 39bed4b0fc2bae267286312f78549dc58b09f13d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 11:42:56 +0200 Subject: [PATCH 03/73] fix lint here --- src/matrix/room/sending/PendingEvent.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 3d0c3fe1..ae51f457 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -197,6 +197,7 @@ export class PendingEvent { if (isTxnId(this._data.redacts)) { return this._data.redacts; } + return null; } setRelatedEventId(eventId) { From 814e92ad929298e72ddedef43455e6660f741503 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 11:43:09 +0200 Subject: [PATCH 04/73] fix missing import --- src/matrix/room/sending/SendQueue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 533a29b3..fd8c45c1 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -16,7 +16,7 @@ limitations under the License. import {SortedArray} from "../../../observable/list/SortedArray.js"; import {ConnectionError} from "../../error.js"; -import {PendingEvent} from "./PendingEvent.js"; +import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; import {REDACTION_TYPE} from "../common.js"; From 94b0bc82efddda3c4ddd50e3313cc9d1579e19d0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 11:44:45 +0200 Subject: [PATCH 05/73] writing relations is async --- src/matrix/room/timeline/persistence/GapWriter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 860265f5..ac265f9d 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -105,7 +105,7 @@ export class GapWriter { } } - _storeEvents(events, startKey, direction, state, txn) { + async _storeEvents(events, startKey, direction, state, txn) { const entries = []; const updatedEntries = []; // events is in reverse chronological order for backwards pagination, @@ -123,7 +123,7 @@ export class GapWriter { txn.timelineEvents.insert(eventStorageEntry); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); directionalAppend(entries, eventEntry, direction); - const updatedRelationTargetEntry = this._relationWriter.writeRelation(eventEntry); + const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry); if (updatedRelationTargetEntry) { updatedEntries.push(updatedRelationTargetEntry); } @@ -246,7 +246,7 @@ export class GapWriter { end = null; } // create entries for all events in chunk, add them to entries - const {entries, updatedEntries} = this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); + const {entries, updatedEntries} = await this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); const fragments = await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn); return {entries, updatedEntries, fragments}; From 780ad44032b5e8a90933680f575de015ef076e89 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 13:15:35 +0200 Subject: [PATCH 06/73] render redacted messages --- .../room/timeline/tiles/RedactedTile.js | 27 +++++++++++++++++++ .../session/room/timeline/tiles/SimpleTile.js | 9 +++++-- .../session/room/timeline/tilesCreator.js | 3 +++ .../room/timeline/entries/EventEntry.js | 4 +++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/domain/session/room/timeline/tiles/RedactedTile.js diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js new file mode 100644 index 00000000..9349f06e --- /dev/null +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -0,0 +1,27 @@ +/* +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 {BaseTextTile} from "./BaseTextTile.js"; + +export class RedactedTile extends BaseTextTile { + get shape() { + return "message-status" + } + + _getBodyAsString() { + return "This message has been deleted."; + } +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index ea5640bd..977e2410 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -84,8 +84,13 @@ export class SimpleTile extends ViewModel { // update received for already included (falls within sort keys) entry updateEntry(entry, params) { - this._entry = entry; - return UpdateAction.Update(params); + if (entry.isRedacted) { + // recreate the tile if the entry becomes redacted + return UpdateAction.Replace(params); + } else { + this._entry = entry; + return UpdateAction.Update(params); + } } // return whether the tile should be removed diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index ed43cd3a..af91cac7 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -16,6 +16,7 @@ limitations under the License. import {GapTile} from "./tiles/GapTile.js"; import {TextTile} from "./tiles/TextTile.js"; +import {RedactedTile} from "./tiles/RedactedTile.js"; import {ImageTile} from "./tiles/ImageTile.js"; import {VideoTile} from "./tiles/VideoTile.js"; import {FileTile} from "./tiles/FileTile.js"; @@ -31,6 +32,8 @@ 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) { diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 5f2496d8..561c9f8e 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -112,4 +112,8 @@ export class EventEntry extends BaseEntry { get relatedEventId() { return this._eventEntry.event.redacts; } + + get isRedacted() { + return !!this._eventEntry.event.unsigned?.redacted_because; + } } \ No newline at end of file From b655c34bbb325a1099ff6538b278cf60d52da254 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 13:20:12 +0200 Subject: [PATCH 07/73] also show reason for redaction --- src/domain/session/room/timeline/tiles/RedactedTile.js | 8 +++++++- src/matrix/room/timeline/entries/EventEntry.js | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index 9349f06e..bf1fc0db 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -22,6 +22,12 @@ export class RedactedTile extends BaseTextTile { } _getBodyAsString() { - return "This message has been deleted."; + const {redactionReason} = this._entry; + if (redactionReason) { + return this.i18n`This message has been deleted (${redactionReason}).`; + + } else { + return this.i18n`This message has been deleted.`; + } } } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 561c9f8e..2c30acfb 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -116,4 +116,11 @@ export class EventEntry extends BaseEntry { get isRedacted() { return !!this._eventEntry.event.unsigned?.redacted_because; } + + get redactionReason() { + const redactionEvent = this._eventEntry.event.unsigned?.redacted_because; + if (redactionEvent) { + return redactionEvent.content?.reason; + } + } } \ No newline at end of file From df9e886f32a227f0e581525820135fd6ea44e4f0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 13:22:54 +0200 Subject: [PATCH 08/73] fix lint --- src/matrix/room/timeline/entries/EventEntry.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 2c30acfb..08379b91 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -122,5 +122,6 @@ export class EventEntry extends BaseEntry { if (redactionEvent) { return redactionEvent.content?.reason; } + return null; } } \ No newline at end of file From 618a32e6c002c97e4e343a5fbac1da62b4942591 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 14:49:54 +0200 Subject: [PATCH 09/73] revert last tried pending event status to waiting when offline so we don't fail the check if we can immediately remove when redacting --- src/matrix/room/sending/PendingEvent.js | 5 +++++ src/matrix/room/sending/SendQueue.js | 1 + 2 files changed, 6 insertions(+) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index ae51f457..3e7971e7 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -88,6 +88,11 @@ export class PendingEvent { this._emitUpdate("status"); } + setWaiting() { + this._status = SendStatus.Waiting; + this._emitUpdate("status"); + } + get status() { return this._status; } get error() { return this._error; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index fd8c45c1..c946a2f5 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -74,6 +74,7 @@ export class SendQueue { if (err instanceof ConnectionError) { this._offline = true; log.set("offline", true); + pendingEvent.setWaiting(); } else { log.catch(err); pendingEvent.setError(err); From 4ce66fc8a19ccfd7c99beb216160a44e8765576e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 14:51:04 +0200 Subject: [PATCH 10/73] allow concurrent removals when iterating pending events so we can remove failed events in the next commit --- src/matrix/room/sending/SendQueue.js | 16 +----- src/observable/list/SortedArray.js | 76 +++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index c946a2f5..4b1088c3 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -47,25 +47,11 @@ export class SendQueue { this._roomEncryption = roomEncryption; } - _nextPendingEvent(current) { - if (!current) { - return this._pendingEvents.get(0); - } else { - const idx = this._pendingEvents.indexOf(current); - if (idx !== -1) { - return this._pendingEvents.get(idx + 1); - } - return; - } - } - _sendLoop(log) { this._isSending = true; this._sendLoopLogItem = log.runDetached("send queue flush", async log => { - let pendingEvent; try { - // eslint-disable-next-line no-cond-assign - while (pendingEvent = this._nextPendingEvent(pendingEvent)) { + for (const pendingEvent of this._pendingEvents) { await log.wrap("send event", async log => { log.set("queueIndex", pendingEvent.queueIndex); try { diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 193661cf..24378658 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -58,6 +58,14 @@ export class SortedArray extends BaseObservableList { } } + _getNext(item) { + let idx = sortedIndex(this._items, item, this._comparator); + while(idx < this._items.length && this._comparator(this._items[idx], item) <= 0) { + idx += 1; + } + return this.get(idx); + } + set(item, updateParams = null) { const idx = sortedIndex(this._items, item, this._comparator); if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { @@ -88,6 +96,72 @@ export class SortedArray extends BaseObservableList { } [Symbol.iterator]() { - return this._items.values(); + return new Iterator(this); } } + +// iterator that works even if the current value is removed while iterating +class Iterator { + constructor(sortedArray) { + this._sortedArray = sortedArray; + this._current = null; + } + + next() { + if (this._sortedArray) { + if (this._current) { + this._current = this._sortedArray._getNext(this._current); + } else { + this._current = this._sortedArray.get(0); + } + if (this._current) { + return {value: this._current}; + } else { + // cause done below + this._sortedArray = null; + } + } + if (!this._sortedArray) { + return {done: true}; + } + } +} + +export function tests() { + return { + "setManyUnsorted": assert => { + const sa = new SortedArray((a, b) => a.localeCompare(b)); + sa.setManyUnsorted(["b", "a", "c"]); + assert.equal(sa.length, 3); + assert.equal(sa.get(0), "a"); + assert.equal(sa.get(1), "b"); + assert.equal(sa.get(2), "c"); + }, + "_getNext": assert => { + const sa = new SortedArray((a, b) => a.localeCompare(b)); + sa.setManyUnsorted(["b", "a", "f"]); + assert.equal(sa._getNext("a"), "b"); + assert.equal(sa._getNext("b"), "f"); + // also finds the next if the value is not in the collection + assert.equal(sa._getNext("c"), "f"); + assert.equal(sa._getNext("f"), undefined); + }, + "iterator with removals": assert => { + const queue = new SortedArray((a, b) => a.idx - b.idx); + queue.setManyUnsorted([{idx: 5}, {idx: 3}, {idx: 1}, {idx: 4}, {idx: 2}]); + const it = queue[Symbol.iterator](); + assert.equal(it.next().value.idx, 1); + assert.equal(it.next().value.idx, 2); + queue.remove(1); + assert.equal(it.next().value.idx, 3); + queue.remove(1); + assert.equal(it.next().value.idx, 4); + queue.remove(1); + assert.equal(it.next().value.idx, 5); + queue.remove(1); + assert.equal(it.next().done, true); + // check done persists + assert.equal(it.next().done, true); + } + } +} \ No newline at end of file From 9721432a8ce372244d7436d7b4ed189eb0a46f73 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 14:52:30 +0200 Subject: [PATCH 11/73] remove pending events that failed because of permanent error so they don't get stuck --- src/matrix/room/sending/SendQueue.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 4b1088c3..1ec789e7 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -63,7 +63,17 @@ export class SendQueue { pendingEvent.setWaiting(); } else { log.catch(err); - pendingEvent.setError(err); + const isPermanentError = err.name === "HomeServerError" && ( + err.statusCode === 400 || // bad request, must be a bug on our end + err.statusCode === 403 || // forbidden + err.statusCode === 404 // not found + ); + if (isPermanentError) { + log.set("remove", true); + await pendingEvent.abort(); + } else { + pendingEvent.setError(err); + } } } }); From 8a8c5569dc246b163caf3619fe27a45289381566 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 14:53:17 +0200 Subject: [PATCH 12/73] provide redact method on tile and room also add some logging --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ++++ src/domain/session/room/timeline/tiles/GapTile.js | 4 ---- src/domain/session/room/timeline/tiles/SimpleTile.js | 4 ++++ src/matrix/room/Room.js | 8 ++++++++ src/matrix/room/sending/SendQueue.js | 3 +++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index a348a249..2e7e5ad7 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -97,4 +97,8 @@ export class BaseMessageTile extends SimpleTile { this.emitChange("isContinuation"); } } + + redact(reason) { + this._room.sendRedaction(this._entry.id, reason); + } } diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index c2cf2f56..1b05c1c8 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -24,10 +24,6 @@ export class GapTile extends SimpleTile { this._error = null; } - get _room() { - return this.getOption("room"); - } - async fill() { // prevent doing this twice if (!this._loading) { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 977e2410..c4935f1d 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -118,4 +118,8 @@ export class SimpleTile extends ViewModel { super.dispose(); } // TilesCollection contract above + + get _room() { + return this.getOption("room"); + } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index c743dcb8..cb145b7d 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -297,6 +297,14 @@ export class Room extends BaseRoom { }); } + /** @public */ + sendRedaction(eventIdOrTxnId, reason, log = null) { + this._platform.logger.wrapOrRun(log, "redact", log => { + log.set("id", this.id); + return this._sendQueue.enqueueRedaction(eventIdOrTxnId, reason, log); + }); + } + /** @public */ async ensureMessageKeyIsShared(log = null) { if (!this._roomEncryption) { diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 1ec789e7..c06669b6 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -201,6 +201,7 @@ export class SendQueue { async enqueueRedaction(eventIdOrTxnId, reason, log) { if (isTxnId(eventIdOrTxnId)) { + log.set("txnIdToRedact", eventIdOrTxnId); const txnId = eventIdOrTxnId; const pe = this._pendingEvents.array.find(pe => pe.txnId === txnId); if (pe && !pe.remoteId && pe.status !== SendStatus.Sending) { @@ -216,6 +217,8 @@ export class SendQueue { // and a bit complicated to fix. return; } + } else { + log.set("eventIdToRedact", eventIdOrTxnId); } await this._enqueueEvent(REDACTION_TYPE, {reason}, null, eventIdOrTxnId, log); } From f271517446e9e4a4c19ddb4054b1885a622545ad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 15:02:14 +0200 Subject: [PATCH 13/73] log redaction during sync --- src/matrix/room/timeline/persistence/GapWriter.js | 8 ++++---- .../room/timeline/persistence/RelationWriter.js | 12 +++++++----- src/matrix/room/timeline/persistence/SyncWriter.js | 4 ++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index ac265f9d..b1091afa 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -105,7 +105,7 @@ export class GapWriter { } } - async _storeEvents(events, startKey, direction, state, txn) { + async _storeEvents(events, startKey, direction, state, txn, log) { const entries = []; const updatedEntries = []; // events is in reverse chronological order for backwards pagination, @@ -123,12 +123,12 @@ 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); + const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log); if (updatedRelationTargetEntry) { updatedEntries.push(updatedRelationTargetEntry); } } - return entries; + return {entries, updatedEntries}; } _findMember(userId, state, events, index, direction) { @@ -246,7 +246,7 @@ export class GapWriter { end = null; } // create entries for all events in chunk, add them to entries - const {entries, updatedEntries} = await this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); + const {entries, updatedEntries} = await this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn, log); const fragments = await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn); return {entries, updatedEntries, fragments}; diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 67a78bb3..144ea40d 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -24,11 +24,11 @@ export class RelationWriter { } // this needs to happen again after decryption too for edits - async writeRelation(sourceEntry, txn) { + async writeRelation(sourceEntry, txn, log) { if (sourceEntry.relatedEventId) { const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); if (target) { - if (this._applyRelation(sourceEntry, target)) { + if (this._applyRelation(sourceEntry, target, log)) { txn.timelineEvents.update(target); return new EventEntry(target, this._fragmentIdComparer); } @@ -37,15 +37,17 @@ export class RelationWriter { return; } - _applyRelation(sourceEntry, target) { + _applyRelation(sourceEntry, target, log) { if (sourceEntry.eventType === REDACTION_TYPE) { - return this._applyRedaction(sourceEntry.event, target.event); + return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, target.event, log)); } else { return false; } } - _applyRedaction(redactionEvent, targetEvent) { + _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) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 8bf31ed2..671b944a 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -174,7 +174,7 @@ export class SyncWriter { txn.timelineEvents.insert(storageEntry); const entry = new EventEntry(storageEntry, this._fragmentIdComparer); entries.push(entry); - const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry); + const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log); if (updatedRelationTargetEntry) { updatedEntries.push(updatedRelationTargetEntry); } @@ -246,7 +246,7 @@ export class SyncWriter { // members are available in the transaction await this._writeStateEvents(roomResponse, memberChanges, timeline?.limited, txn, log); const {currentKey, entries, updatedEntries} = - await this._writeTimeline(entries, updatedEntries, timeline, this._lastLiveKey, memberChanges, txn, log); + await this._writeTimeline(timeline, this._lastLiveKey, memberChanges, txn, log); log.set("memberChanges", memberChanges.size); return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; } From 619cf9bcbb17c9eebd00ab2e958885665f9b9d7c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 15:02:24 +0200 Subject: [PATCH 14/73] this should be filter rather than find, we iterate it --- src/matrix/room/sending/SendQueue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index c06669b6..287831e8 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -104,7 +104,7 @@ export class SendQueue { // the relatedTxnId to a related event id, they need to do so now. // We ensure this by writing the new remote id for the pending event and all related events // with unresolved relatedTxnId in the queue in one transaction. - const relatedEvents = this._pendingEvents.array.find(pe => pe.relatedTxnId === pendingEvent.txnId); + const relatedEvents = this._pendingEvents.array.filter(pe => pe.relatedTxnId === pendingEvent.txnId); const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); try { await this._tryUpdateEventWithTxn(pendingEvent, txn); From c3fb35848bac65ff47229c99c222423b89d50878 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 May 2021 15:02:45 +0200 Subject: [PATCH 15/73] emit update when receiving event id for related event ahead in the queue --- src/matrix/room/sending/SendQueue.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 287831e8..5fa6dca6 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -111,6 +111,8 @@ export class SendQueue { for (const relatedPE of relatedEvents) { relatedPE.setRelatedEventId(pendingEvent.remoteId); await this._tryUpdateEventWithTxn(relatedPE, txn); + // emit that we now have a related remote id + this._pendingEvents.update(relatedPE) } } catch (err) { txn.abort(); From c934049523c3148a470b6caad142d3b05facbe7d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 May 2021 10:47:48 +0200 Subject: [PATCH 16/73] also resolve related event ids when removing remote echo during sync as /sync races with /send, and remote echo may happen first. It's important for local echo that the pending redaction/relation will also get attached to the remote echo before /send returns, otherwise the remote echo would be "unannotated" until /send returns --- src/matrix/room/BaseRoom.js | 4 +- src/matrix/room/Room.js | 6 +-- src/matrix/room/sending/PendingEvent.js | 24 ++++------- src/matrix/room/sending/SendQueue.js | 53 ++++++++++++++++--------- 4 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index e6e7ae33..9cb38974 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -263,7 +263,7 @@ export class BaseRoom extends EventEmitter { let gapResult; try { // detect remote echos of pending messages in the gap - extraGapFillChanges = this._writeGapFill(response.chunk, txn, log); + extraGapFillChanges = await this._writeGapFill(response.chunk, txn, log); // write new events into gap const gapWriter = new GapWriter({ roomId: this._roomId, @@ -300,7 +300,7 @@ export class BaseRoom extends EventEmitter { JoinedRoom uses this update remote echos. */ // eslint-disable-next-line no-unused-vars - _writeGapFill(chunk, txn, log) {} + async _writeGapFill(chunk, txn, log) {} _applyGapFill() {} /** @public */ diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index cb145b7d..da9eef52 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -159,7 +159,7 @@ export class Room extends BaseRoom { } let removedPendingEvents; if (Array.isArray(roomResponse.timeline?.events)) { - removedPendingEvents = this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); + removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); } return { summaryChanges, @@ -280,8 +280,8 @@ export class Room extends BaseRoom { } } - _writeGapFill(gapChunk, txn, log) { - const removedPendingEvents = this._sendQueue.removeRemoteEchos(gapChunk, txn, log); + async _writeGapFill(gapChunk, txn, log) { + const removedPendingEvents = await this._sendQueue.removeRemoteEchos(gapChunk, txn, log); return removedPendingEvents; } diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 3e7971e7..ef5d086e 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -16,7 +16,6 @@ limitations under the License. import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; import {REDACTION_TYPE} from "../common.js"; -import {isTxnId} from "../../common.js"; export const SendStatus = createEnum( "Waiting", @@ -49,6 +48,13 @@ export class PendingEvent { get txnId() { return this._data.txnId; } get remoteId() { return this._data.remoteId; } get content() { return this._data.content; } + get relatedTxnId() { return this._data.relatedTxnId; } + get relatedEventId() { return this._data.relatedEventId; } + + setRelatedEventId(eventId) { + this._data.relatedEventId = eventId; + } + get data() { return this._data; } getAttachment(key) { @@ -164,10 +170,9 @@ export class PendingEvent { const eventType = this._data.encryptedEventType || this._data.eventType; const content = this._data.encryptedContent || this._data.content; if (eventType === REDACTION_TYPE) { - // TODO: should we double check here that this._data.redacts is not a txnId here anymore? this._sendRequest = hsApi.redact( this.roomId, - this._data.redacts, + this._data.relatedEventId, this.txnId, content, {log} @@ -197,17 +202,4 @@ export class PendingEvent { } } } - - get relatedTxnId() { - if (isTxnId(this._data.redacts)) { - return this._data.redacts; - } - return null; - } - - setRelatedEventId(eventId) { - if (this._data.redacts) { - this._data.redacts = eventId; - } - } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 5fa6dca6..45bcb519 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -104,16 +104,11 @@ export class SendQueue { // the relatedTxnId to a related event id, they need to do so now. // We ensure this by writing the new remote id for the pending event and all related events // with unresolved relatedTxnId in the queue in one transaction. - const relatedEvents = this._pendingEvents.array.filter(pe => pe.relatedTxnId === pendingEvent.txnId); const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); try { await this._tryUpdateEventWithTxn(pendingEvent, txn); - for (const relatedPE of relatedEvents) { - relatedPE.setRelatedEventId(pendingEvent.remoteId); - await this._tryUpdateEventWithTxn(relatedPE, txn); - // emit that we now have a related remote id - this._pendingEvents.update(relatedPE) - } + await this._resolveRemoteIdInPendingRelations( + pendingEvent.txnId, pendingEvent.remoteId, txn); } catch (err) { txn.abort(); throw err; @@ -122,7 +117,20 @@ export class SendQueue { } } - removeRemoteEchos(events, txn, parentLog) { + async _resolveRemoteIdInPendingRelations(txnId, remoteId, txn) { + const relatedEventWithoutRemoteId = this._pendingEvents.array.filter(pe => { + return pe.relatedTxnId === txnId && pe.relatedEventId !== remoteId; + }); + for (const relatedPE of relatedEventWithoutRemoteId) { + relatedPE.setRelatedEventId(remoteId); + await this._tryUpdateEventWithTxn(relatedPE, txn); + // emit that we now have a related remote id + // this._pendingEvents.update(relatedPE); + } + return relatedEventWithoutRemoteId; + } + + async removeRemoteEchos(events, txn, parentLog) { const removed = []; for (const event of events) { const txnId = event.unsigned && event.unsigned.transaction_id; @@ -134,9 +142,11 @@ export class SendQueue { } if (idx !== -1) { const pendingEvent = this._pendingEvents.get(idx); - parentLog.log({l: "removeRemoteEcho", queueIndex: pendingEvent.queueIndex, remoteId: event.event_id, txnId}); + const remoteId = event.event_id; + parentLog.log({l: "removeRemoteEcho", queueIndex: pendingEvent.queueIndex, remoteId, txnId}); txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); removed.push(pendingEvent); + await this._resolveRemoteIdInPendingRelations(txnId, remoteId, txn); } } return removed; @@ -184,12 +194,12 @@ export class SendQueue { } async enqueueEvent(eventType, content, attachments, log) { - await this._enqueueEvent(eventType, content, attachments, null, log); + await this._enqueueEvent(eventType, content, attachments, null, null, log); } - async _enqueueEvent(eventType, content, attachments, redacts, log) { - const pendingEvent = await this._createAndStoreEvent(eventType, content, redacts, attachments); + async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, relatedTxnId, relatedEventId, attachments); this._pendingEvents.set(pendingEvent); log.set("queueIndex", pendingEvent.queueIndex); log.set("pendingEvents", this._pendingEvents.length); @@ -202,8 +212,11 @@ export class SendQueue { } async enqueueRedaction(eventIdOrTxnId, reason, log) { + let relatedTxnId; + let relatedEventId; if (isTxnId(eventIdOrTxnId)) { - log.set("txnIdToRedact", eventIdOrTxnId); + relatedTxnId = eventIdOrTxnId; + log.set("relatedTxnId", eventIdOrTxnId); const txnId = eventIdOrTxnId; const pe = this._pendingEvents.array.find(pe => pe.txnId === txnId); if (pe && !pe.remoteId && pe.status !== SendStatus.Sending) { @@ -211,7 +224,9 @@ export class SendQueue { // just remove it from the queue await pe.abort(); return; - } else if (!pe) { + } else if (pe) { + relatedEventId = pe.remoteId; + } else { // we don't have the pending event anymore, // the remote echo must have arrived in the meantime. // we could look for it in the timeline, but for now @@ -220,9 +235,10 @@ export class SendQueue { return; } } else { - log.set("eventIdToRedact", eventIdOrTxnId); + relatedEventId = eventIdOrTxnId; + log.set("relatedEventId", relatedEventId); } - await this._enqueueEvent(REDACTION_TYPE, {reason}, null, eventIdOrTxnId, log); + await this._enqueueEvent(REDACTION_TYPE, {reason}, null, relatedTxnId, relatedEventId, log); } get pendingEvents() { @@ -248,7 +264,7 @@ export class SendQueue { } } - async _createAndStoreEvent(eventType, content, redacts, attachments) { + async _createAndStoreEvent(eventType, content, relatedTxnId, relatedEventId, attachments) { const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; try { @@ -261,7 +277,8 @@ export class SendQueue { queueIndex, eventType, content, - redacts, + relatedTxnId, + relatedEventId, txnId: makeTxnId(), needsEncryption, needsUpload: !!attachments From b55efb7f1134442968eb7168e7723083dcb36c14 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 May 2021 16:58:04 +0200 Subject: [PATCH 17/73] ensure updateEntries is always set in the result of GapWriter --- src/matrix/room/timeline/persistence/GapWriter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index b1091afa..67668298 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -231,7 +231,7 @@ export class GapWriter { if (chunk.length === 0) { fragmentEntry.edgeReached = true; await txn.timelineFragments.update(fragmentEntry.fragment); - return {entries: [fragmentEntry], fragments: []}; + return {entries: [fragmentEntry], updatedEntries: [], fragments: []}; } // find last event in fragment so we get the eventIndex to begin creating keys at From af458105827691f5a1d763fcbbf97f23ce47f2bd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 May 2021 16:59:29 +0200 Subject: [PATCH 18/73] add support for redactions (and relations) local echo --- src/matrix/room/sending/SendQueue.js | 11 ++- src/matrix/room/timeline/Timeline.js | 75 +++++++++++++++---- .../room/timeline/entries/BaseEventEntry.js | 55 ++++++++++++++ .../room/timeline/entries/EventEntry.js | 6 +- .../timeline/entries/PendingEventEntry.js | 9 ++- 5 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 src/matrix/room/timeline/entries/BaseEventEntry.js diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 45bcb519..1c5415e5 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -216,12 +216,12 @@ export class SendQueue { let relatedEventId; if (isTxnId(eventIdOrTxnId)) { relatedTxnId = eventIdOrTxnId; - log.set("relatedTxnId", eventIdOrTxnId); const txnId = eventIdOrTxnId; const pe = this._pendingEvents.array.find(pe => pe.txnId === txnId); if (pe && !pe.remoteId && pe.status !== SendStatus.Sending) { // haven't started sending this event yet, // just remove it from the queue + log.set("remove", relatedTxnId); await pe.abort(); return; } else if (pe) { @@ -236,8 +236,15 @@ export class SendQueue { } } else { relatedEventId = eventIdOrTxnId; - log.set("relatedEventId", relatedEventId); + const pe = this._pendingEvents.array.find(pe => pe.remoteId === relatedEventId); + if (pe) { + // also set the txn id just in case that an event id was passed + // for relating to a pending event that is still waiting for the remote echo + relatedTxnId = pe.txnId; + } } + log.set("relatedTxnId", eventIdOrTxnId); + log.set("relatedEventId", relatedEventId); await this._enqueueEvent(REDACTION_TYPE, {reason}, null, relatedTxnId, relatedEventId, log); } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 74040586..0b6fb5ec 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -1,5 +1,6 @@ /* 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. @@ -28,7 +29,9 @@ export class Timeline { this._closeCallback = closeCallback; this._fragmentIdComparer = fragmentIdComparer; this._disposables = new Disposables(); - this._remoteEntries = new SortedArray((a, b) => a.compare(b)); + this._pendingEvents = pendingEvents; + this._clock = clock; + this._remoteEntries = null; this._ownMember = null; this._timelineReader = new TimelineReader({ roomId: this._roomId, @@ -36,17 +39,7 @@ export class Timeline { fragmentIdComparer: this._fragmentIdComparer }); this._readerRequest = null; - let localEntries; - if (pendingEvents) { - localEntries = new MappedList(pendingEvents, pe => { - return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock}); - }, (pee, params) => { - pee.notifyUpdate(params); - }); - } else { - localEntries = new ObservableArray(); - } - this._allEntries = new ConcatList(this._remoteEntries, localEntries); + this._allEntries = null; } /** @package */ @@ -69,12 +62,58 @@ export class Timeline { const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); try { const entries = await readerRequest.complete(); - this._remoteEntries.setManySorted(entries); + this._setupEntries(entries); } finally { this._disposables.disposeTracked(readerRequest); } } + _setupEntries(timelineEntries) { + this._remoteEntries = new SortedArray((a, b) => a.compare(b)); + this._remoteEntries.setManySorted(timelineEntries); + if (this._pendingEvents) { + this._localEntries = new MappedList(this._pendingEvents, pe => { + const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock}); + this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.addLocalRelation(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)); + }); + // we need a hook for when a pee is removed, so we can remove the local relation + } else { + this._localEntries = new ObservableArray(); + } + this._allEntries = new ConcatList(this._remoteEntries, this._localEntries); + } + + _applyAndEmitLocalRelationChange(pe, updater) { + // first, look in local entries (separately, as it has its own update mechanism) + const foundInLocalEntries = this._localEntries.findAndUpdate( + e => e.id === pe.relatedTxnId, + e => { + const params = updater(e); + return params ? params : false; + }, + ); + // now look in remote entries + if (!foundInLocalEntries && pe.relatedEventId) { + // TODO: ideally iterate in reverse as target is likely to be most recent, + // but not easy through ObservableList contract + for (const entry of this._allEntries) { + if (pe.relatedEventId === entry.id) { + const params = updater(entry); + if (params) { + this._remoteEntries.update(entry, params); + } + return; + } + } + } + } + updateOwnMember(member) { this._ownMember = member; } @@ -89,6 +128,15 @@ export class Timeline { /** @package */ addOrReplaceEntries(newEntries) { + // find any local relations to this new remote event + for (const pee of this._localEntries) { + // this will work because we set relatedEventId when removing remote echos + if (pee.relatedEventId) { + const relationTarget = newEntries.find(e => e.id === pee.relatedEventId); + // no need to emit here as this entry is about to be added + relationTarget?.addLocalRelation(pee); + } + } this._remoteEntries.setManySorted(newEntries); } @@ -128,6 +176,7 @@ export class Timeline { return entry; } } + return null; } /** @public */ diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js new file mode 100644 index 00000000..c895f538 --- /dev/null +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -0,0 +1,55 @@ +/* +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 {BaseEntry} from "./BaseEntry.js"; +import {REDACTION_TYPE} from "../../common.js"; + +export class BaseEventEntry extends BaseEntry { + constructor(fragmentIdComparer) { + super(fragmentIdComparer); + this._localRedactCount = 0; + } + + get isRedacted() { + return this._localRedactCount > 0; + } + + /** + aggregates local relation. + @return [string] returns the name of the field that has changed, if any + */ + addLocalRelation(entry) { + if (entry.eventType === REDACTION_TYPE) { + this._localRedactCount += 1; + if (this._localRedactCount === 1) { + return "isRedacted"; + } + } + } + + /** + deaggregates local relation. + @return [string] returns the name of the field that has changed, if any + */ + removeLocalRelation(entry) { + if (entry.eventType === REDACTION_TYPE) { + this._localRedactCount -= 1; + if (this._localRedactCount === 0) { + return "isRedacted"; + } + } + } +} \ 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 08379b91..d3287799 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseEntry} from "./BaseEntry.js"; +import {BaseEventEntry} from "./BaseEventEntry.js"; import {getPrevContentFromStateEvent} from "../../common.js"; -export class EventEntry extends BaseEntry { +export class EventEntry extends BaseEventEntry { constructor(eventEntry, fragmentIdComparer) { super(fragmentIdComparer); this._eventEntry = eventEntry; @@ -114,7 +114,7 @@ export class EventEntry extends BaseEntry { } get isRedacted() { - return !!this._eventEntry.event.unsigned?.redacted_because; + return super.isRedacted || !!this._eventEntry.event.unsigned?.redacted_because; } get redactionReason() { diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 7a22d426..64771ffc 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js"; +import {PENDING_FRAGMENT_ID} from "./BaseEntry.js"; +import {BaseEventEntry} from "./BaseEventEntry.js"; -export class PendingEventEntry extends BaseEntry { +export class PendingEventEntry extends BaseEventEntry { constructor({pendingEvent, member, clock}) { super(null); this._pendingEvent = pendingEvent; @@ -80,4 +81,8 @@ export class PendingEventEntry extends BaseEntry { notifyUpdate() { } + + get relatedEventId() { + return this._pendingEvent.relatedEventId; + } } From cb622be653f650ad0d98a4476053aa32c78bfc41 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 25 May 2021 12:58:20 +0200 Subject: [PATCH 19/73] rerender tile when becoming or stopped being redacted --- src/domain/session/room/timeline/tiles/RedactedTile.js | 2 +- src/domain/session/room/timeline/tiles/SimpleTile.js | 9 +++++---- src/platform/web/ui/session/room/TimelineList.js | 1 + src/platform/web/ui/session/room/timeline/common.js | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index bf1fc0db..81b286a5 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -18,7 +18,7 @@ import {BaseTextTile} from "./BaseTextTile.js"; export class RedactedTile extends BaseTextTile { get shape() { - return "message-status" + return "redacted"; } _getBodyAsString() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index c4935f1d..29f9980b 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -83,13 +83,14 @@ export class SimpleTile extends ViewModel { } // update received for already included (falls within sort keys) entry - updateEntry(entry, params) { - if (entry.isRedacted) { + updateEntry(entry, param) { + const renderedAsRedacted = this.shape === "redacted"; + if (entry.isRedacted !== renderedAsRedacted) { // recreate the tile if the entry becomes redacted - return UpdateAction.Replace(params); + return UpdateAction.Replace("shape"); } else { this._entry = entry; - return UpdateAction.Update(params); + return UpdateAction.Update(param); } } diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 791e23a3..b55c2450 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -29,6 +29,7 @@ function viewClassForEntry(entry) { case "announcement": return AnnouncementView; case "message": case "message-status": + case "redacted": return TextMessageView; case "image": return ImageView; case "video": return VideoView; diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 22bcd6b1..3b53245e 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -24,7 +24,7 @@ export function renderMessage(t, vm, children) { unsent: vm.isUnsent, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file", + messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file" || vm.shape === "redacted", }; const profile = t.div({className: "profile"}, [ From ce7147e463e0b2b887272f0f2ef54598ea3b0ff3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 26 May 2021 13:07:56 +0200 Subject: [PATCH 20/73] put redactions in their own view, and allow aborting while still queued --- .../room/timeline/tiles/RedactedTile.js | 25 ++++++++---- .../room/timeline/entries/BaseEventEntry.js | 39 +++++++++++++++---- .../web/ui/session/room/TimelineList.js | 4 +- .../ui/session/room/timeline/RedactedView.js | 27 +++++++++++++ 4 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 src/platform/web/ui/session/room/timeline/RedactedView.js diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index 81b286a5..2363f91c 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -14,20 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseTextTile} from "./BaseTextTile.js"; +import {BaseMessageTile} from "./BaseMessageTile.js"; -export class RedactedTile extends BaseTextTile { +export class RedactedTile extends BaseMessageTile { get shape() { return "redacted"; } - _getBodyAsString() { + get description() { const {redactionReason} = this._entry; - if (redactionReason) { - return this.i18n`This message has been deleted (${redactionReason}).`; - + if (this.isRedacting) { + return this.i18n`This message is being deleted …`; } else { - return this.i18n`This message has been deleted.`; + if (redactionReason) { + return this.i18n`This message has been deleted (${redactionReason}).`; + } else { + return this.i18n`This message has been deleted.`; + } } } + + get isRedacting() { + return this._entry.isRedacting; + } + + abortPendingRedaction() { + return this._entry.abortPendingRedaction(); + } } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index c895f538..4706f5f2 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -20,11 +20,17 @@ import {REDACTION_TYPE} from "../../common.js"; export class BaseEventEntry extends BaseEntry { constructor(fragmentIdComparer) { super(fragmentIdComparer); - this._localRedactCount = 0; + this._pendingRedactions = null; + } + + get isRedacting() { + return !!this._pendingRedactions; } get isRedacted() { - return this._localRedactCount > 0; + return this.isRedacting; + } + } /** @@ -33,8 +39,11 @@ export class BaseEventEntry extends BaseEntry { */ addLocalRelation(entry) { if (entry.eventType === REDACTION_TYPE) { - this._localRedactCount += 1; - if (this._localRedactCount === 1) { + if (!this._pendingRedactions) { + this._pendingRedactions = []; + } + this._pendingRedactions.push(entry); + if (this._pendingRedactions.length === 1) { return "isRedacted"; } } @@ -45,11 +54,25 @@ 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) { - this._localRedactCount -= 1; - if (this._localRedactCount === 0) { - return "isRedacted"; + if (entry.eventType === REDACTION_TYPE && this._pendingRedactions) { + const countBefore = this._pendingRedactions.length; + this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry); + if (this._pendingRedactions.length === 0) { + this._pendingRedactions = null; + if (countBefore !== 0) { + return "isRedacted"; + } } } } + + async abortPendingRedaction() { + if (this._pendingRedactions) { + for (const pee of this._pendingRedactions) { + await pee.pendingEvent.abort(); + } + // removing the pending events will call removeLocalRelation, + // so don't clear _pendingRedactions here + } + } } \ No newline at end of file diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index b55c2450..81763c05 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -22,6 +22,7 @@ import {VideoView} from "./timeline/VideoView.js"; import {FileView} from "./timeline/FileView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; +import {RedactedView} from "./timeline/RedactedView.js"; function viewClassForEntry(entry) { switch (entry.shape) { @@ -29,12 +30,13 @@ function viewClassForEntry(entry) { case "announcement": return AnnouncementView; case "message": case "message-status": - case "redacted": return TextMessageView; case "image": return ImageView; case "video": return VideoView; case "file": return FileView; case "missing-attachment": return MissingAttachmentView; + case "redacted": + return RedactedView; } } diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js new file mode 100644 index 00000000..6e9bf105 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -0,0 +1,27 @@ +/* +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 {TemplateView} from "../../../general/TemplateView.js"; +import {renderMessage} from "./common.js"; + +export class RedactedView extends TemplateView { + render(t, vm) { + const cancelButton = t.if(vm => vm.isRedacting, t => t.button({onClick: () => vm.abortPendingRedaction()}, "Cancel")); + return renderMessage(t, vm, + [t.p([vm => vm.description, " ", cancelButton, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] + ); + } +} \ No newline at end of file From a5d5c55835b1210f6b099251453c2faadd496738 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 26 May 2021 13:08:33 +0200 Subject: [PATCH 21/73] MappedList.findAndUpdate --- src/observable/list/MappedList.js | 79 ++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/observable/list/MappedList.js b/src/observable/list/MappedList.js index 139a6e73..0bc4065a 100644 --- a/src/observable/list/MappedList.js +++ b/src/observable/list/MappedList.js @@ -1,5 +1,6 @@ /* 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. @@ -17,11 +18,12 @@ limitations under the License. import {BaseObservableList} from "./BaseObservableList.js"; export class MappedList extends BaseObservableList { - constructor(sourceList, mapper, updater) { + constructor(sourceList, mapper, updater, removeCallback) { super(); this._sourceList = sourceList; this._mapper = mapper; this._updater = updater; + this._removeCallback = removeCallback; this._sourceUnsubscribe = null; this._mappedValues = null; } @@ -56,6 +58,9 @@ export class MappedList extends BaseObservableList { onRemove(index) { const mappedValue = this._mappedValues[index]; this._mappedValues.splice(index, 1); + if (this._removeCallback) { + this._removeCallback(mappedValue); + } this.emitRemove(index, mappedValue); } @@ -70,6 +75,21 @@ export class MappedList extends BaseObservableList { this._sourceUnsubscribe(); } + findAndUpdate(predicate, updater) { + const index = this._mappedValues.findIndex(predicate); + if (index !== -1) { + const mappedValue = this._mappedValues[index]; + // allow bailing out of sending an emit if updater determined its not needed + const params = updater(mappedValue); + if (params !== false) { + this.emitUpdate(index, mappedValue, params); + } + // found + return true; + } + return false; + } + get length() { return this._mappedValues.length; } @@ -79,6 +99,8 @@ export class MappedList extends BaseObservableList { } } +import {ObservableArray} from "./ObservableArray.js"; + export async function tests() { class MockList extends BaseObservableList { get length() { @@ -126,6 +148,59 @@ export async function tests() { source.emitUpdate(0, 7); assert(fired); unsubscribe(); - } + }, + "test findAndUpdate not found": assert => { + const source = new ObservableArray([1, 3, 4]); + const mapped = new MappedList( + source, + n => {return n*n;} + ); + mapped.subscribe({ + onUpdate() { assert.fail(); } + }); + assert.equal(mapped.findAndUpdate( + n => n === 100, + () => assert.fail() + ), false); + }, + "test findAndUpdate found but updater bails out of update": assert => { + const source = new ObservableArray([1, 3, 4]); + const mapped = new MappedList( + source, + n => {return n*n;} + ); + mapped.subscribe({ + onUpdate() { assert.fail(); } + }); + let fired = false; + assert.equal(mapped.findAndUpdate( + n => n === 9, + n => { + assert.equal(n, 9); + fired = true; + return false; + } + ), true); + assert.equal(fired, true); + }, + "test findAndUpdate emits update": assert => { + const source = new ObservableArray([1, 3, 4]); + const mapped = new MappedList( + source, + n => {return n*n;} + ); + let fired = false; + mapped.subscribe({ + onUpdate(idx, n, params) { + assert.equal(idx, 1); + assert.equal(n, 9); + assert.equal(params, "param"); + fired = true; + } + }); + assert.equal(mapped.findAndUpdate(n => n === 9, () => "param"), true); + assert.equal(fired, true); + }, + }; } From ca4d09e923ae795d08fc2c69102cc571ef0828ae Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 26 May 2021 13:08:54 +0200 Subject: [PATCH 22/73] add logging and return promise from Tile.redact --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 2e7e5ad7..3e4e1569 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -98,7 +98,7 @@ export class BaseMessageTile extends SimpleTile { } } - redact(reason) { - this._room.sendRedaction(this._entry.id, reason); + redact(reason, log) { + return this._room.sendRedaction(this._entry.id, reason, log); } } From da02b5fe2de8df94c864f4c5023fe9e56c05caa3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 26 May 2021 13:10:19 +0200 Subject: [PATCH 23/73] transfer local echo state when replacing event entry e.g. after decryption or remote echo of other relation comes in --- src/matrix/room/timeline/Timeline.js | 4 +++- .../room/timeline/entries/BaseEventEntry.js | 5 +++++ src/observable/list/SortedArray.js | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 0b6fb5ec..36bcc110 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -122,7 +122,9 @@ export class Timeline { for (const entry of entries) { // this will use the comparator and thus // check for equality using the compare method in BaseEntry - this._remoteEntries.update(entry); + this._remoteEntries.findAndUpdate(entry, (previousEntry, entry) => { + entry.transferLocalEchoState(previousEntry); + }); } } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 4706f5f2..a3748c8b 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -31,6 +31,11 @@ export class BaseEventEntry extends BaseEntry { return this.isRedacting; } + // when replacing an entry, local echo state can be transfered here + transferLocalEchoState(oldEntry) { + if (oldEntry._pendingRedactions) { + this._pendingRedactions = oldEntry._pendingRedactions; + } } /** diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 24378658..d2663f37 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -41,6 +41,22 @@ export class SortedArray extends BaseObservableList { } } + findAndUpdate(newValue, updater) { + const index = this.indexOf(newValue); + if (index !== -1) { + const oldValue = this._items[index]; + // allow bailing out of sending an emit if updater determined its not needed + const params = updater(oldValue, newValue); + if (params !== false) { + this._items[index] = newValue; + this.emitUpdate(index, newValue, params); + } + // found + return true; + } + return false; + } + update(item, updateParams = null) { const idx = this.indexOf(item); if (idx !== -1) { From 15048bd9c34c4227e7d73ba3489120a9eabd95df Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 26 May 2021 13:11:20 +0200 Subject: [PATCH 24/73] very basic redact button on all text messages --- src/platform/web/ui/session/room/timeline/TextMessageView.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index dc5550ee..6a3b8ed5 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -22,8 +22,9 @@ import {renderMessage} from "./common.js"; export class TextMessageView extends TemplateView { render(t, vm) { const bodyView = t.mapView(vm => vm.body, body => new BodyView(body)); + const redactButton = t.button({onClick: () => vm.redact()}, "Redact"); return renderMessage(t, vm, - [t.p([bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] + [t.p([bodyView, redactButton, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] ); } } From 56495c9d13da069332db0743fb17fc3a18f79fb3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 09:10:10 +0200 Subject: [PATCH 25/73] fix gap failing to fill 2nd time + unit regression test --- .../session/room/timeline/tiles/GapTile.js | 28 +++++++++++++++++++ .../session/room/timeline/tiles/SimpleTile.js | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 1b05c1c8..dbf5cf63 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -72,3 +72,31 @@ export class GapTile extends SimpleTile { return null; } } + +import {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry.js"; +export function tests() { + return { + "uses updated token to fill": async assert => { + let currentToken = 5; + const fragment = { + id: 0, + previousToken: currentToken, + roomId: "!abc" + }; + const room = { + async fillGap(entry) { + console.log(entry.token, currentToken); + assert.equal(entry.token, currentToken); + currentToken += 1; + const newEntry = entry.withUpdatedFragment(Object.assign({}, fragment, {previousToken: currentToken})); + tile.updateEntry(newEntry); + } + }; + const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), room}); + await tile.fill(); + await tile.fill(); + await tile.fill(); + assert.equal(currentToken, 8); + } + } +} \ No newline at end of file diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 29f9980b..07529b51 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -85,7 +85,7 @@ export class SimpleTile extends ViewModel { // update received for already included (falls within sort keys) entry updateEntry(entry, param) { const renderedAsRedacted = this.shape === "redacted"; - if (entry.isRedacted !== renderedAsRedacted) { + if (!entry.isGap && entry.isRedacted !== renderedAsRedacted) { // recreate the tile if the entry becomes redacted return UpdateAction.Replace("shape"); } else { From 2b5dcff836ae596c666dbafe1a23eb8e6215c7ef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 09:11:13 +0200 Subject: [PATCH 26/73] consistent naming --- src/matrix/room/timeline/persistence/RelationWriter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 144ea40d..45716d04 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -37,9 +37,9 @@ export class RelationWriter { return; } - _applyRelation(sourceEntry, target, log) { + _applyRelation(sourceEntry, targetEntry, log) { if (sourceEntry.eventType === REDACTION_TYPE) { - return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, target.event, log)); + return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log)); } else { return false; } From afc3db2f33245cd92b0d52a88a708853a6c88c56 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 09:11:57 +0200 Subject: [PATCH 27/73] unrelated todo note for later --- src/matrix/room/BaseRoom.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 9cb38974..f3d2a707 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -163,6 +163,7 @@ export class BaseRoom extends EventEmitter { return request; } + // TODO: move this to Room async _getSyncRetryDecryptEntries(newKeys, roomEncryption, txn) { const entriesPerKey = await Promise.all(newKeys.map(async key => { const retryEventIds = await roomEncryption.getEventIdsForMissingKey(key, txn); From a93b1af0470408ee6f62b7858d34882cce85e5b8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 09:16:25 +0200 Subject: [PATCH 28/73] ensure these don't fail on a gap entry --- src/matrix/room/timeline/entries/FragmentBoundaryEntry.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js index f43f273b..081d18a2 100644 --- a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js +++ b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js @@ -133,4 +133,8 @@ export class FragmentBoundaryEntry extends BaseEntry { createNeighbourEntry(neighbour) { return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer); } + + transferLocalEchoState() {} + addLocalRelation() {} + removeLocalRelation() {} } From a8e43d4850e1a776d51de30420ee2176f1a1f3fd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 09:18:22 +0200 Subject: [PATCH 29/73] remove leftover logging --- src/domain/session/room/timeline/tiles/GapTile.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index dbf5cf63..1e227876 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -85,7 +85,6 @@ export function tests() { }; const room = { async fillGap(entry) { - console.log(entry.token, currentToken); assert.equal(entry.token, currentToken); currentToken += 1; const newEntry = entry.withUpdatedFragment(Object.assign({}, fragment, {previousToken: currentToken})); From c6e2607f1fd1c4efab5e34f55f903617179e6d11 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 10:02:05 +0200 Subject: [PATCH 30/73] guard against updates emitted while populating during first subscription This came up now because Timeline uses a MappedList to map PendingEvents to PendingEventEntries. In the map function, we setup links between entries to support local echo for relations. When opening a timeline that has unsent relations, the initial populating of the MappedList will try to emit an update for the target entry in remoteEntries. This all happens while the ListView of the timeline is calling subscribe and all collections in the chain are populating themselves based on their sources. This usually entails calling subscribe on the source, and now you are subscribed, iterate over the source (as you're not allowed to query an unsubscribed observable collection, as it might not be populated yet, and even if it did, it wouldn't be guaranteed to be up to date as events aren't flowing yet). So in this concrete example, TilesCollection hadn't populated its tiles yet and when the update to the target of the unsent relation reached TilesCollection, the tiles array was still null and it crashed. I thought what would be the best way to fix this and have a solid model for observable collections to ensure they are always compatible with each other. I considered splitting up the subscription process in two steps where you'd first populate the source and then explicitly start events flowing. I didn't go with this way because it's really only updates that make sense to be emitted during setup. A missed update wouldn't usually bring the collections out of sync like a missed add or remove would. It would just mean the UI isn't updated (or any subsequent filtered collections are not updated), but this should be fine to ignore during setup, as you can rely on the subscribing collections down the chain picking up the update while populating. If we ever want to support add or remove events during setup, we would have to explicitly support them, but for now they are correct to throw. So for now, just ignore update events that happen during setup where needed. --- src/domain/session/room/timeline/TilesCollection.js | 4 ++++ src/observable/list/ConcatList.js | 10 ++++++---- src/observable/list/MappedList.js | 4 ++++ src/observable/list/SortedMapList.js | 4 ++++ src/observable/map/FilteredMap.js | 4 ++++ src/observable/map/JoinedMap.js | 4 ++++ src/observable/map/MappedMap.js | 4 ++++ 7 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 2a53c9d4..f8c55e3a 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -143,6 +143,10 @@ export class TilesCollection extends BaseObservableList { } onUpdate(index, entry, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._tiles) { + return; + } const tileIdx = this._findTileIdx(entry); const tile = this._findTileAtIdx(entry, tileIdx); if (tile) { diff --git a/src/observable/list/ConcatList.js b/src/observable/list/ConcatList.js index a51ddd55..d395e807 100644 --- a/src/observable/list/ConcatList.js +++ b/src/observable/list/ConcatList.js @@ -33,10 +33,7 @@ export class ConcatList extends BaseObservableList { } onSubscribeFirst() { - this._sourceUnsubscribes = []; - for (const sourceList of this._sourceLists) { - this._sourceUnsubscribes.push(sourceList.subscribe(this)); - } + this._sourceUnsubscribes = this._sourceLists.map(sourceList => sourceList.subscribe(this)); } onUnsubscribeLast() { @@ -62,6 +59,11 @@ export class ConcatList extends BaseObservableList { } onUpdate(index, value, params, sourceList) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + // as we are not supposed to call `length` on any uninitialized list + if (!this._sourceUnsubscribes) { + return; + } this.emitUpdate(this._offsetForSource(sourceList) + index, value, params); } diff --git a/src/observable/list/MappedList.js b/src/observable/list/MappedList.js index 0bc4065a..1aed299f 100644 --- a/src/observable/list/MappedList.js +++ b/src/observable/list/MappedList.js @@ -48,6 +48,10 @@ export class MappedList extends BaseObservableList { } onUpdate(index, value, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._mappedValues) { + return; + } const mappedValue = this._mappedValues[index]; if (this._updater) { this._updater(mappedValue, params, value); diff --git a/src/observable/list/SortedMapList.js b/src/observable/list/SortedMapList.js index 27003cba..babf5c35 100644 --- a/src/observable/list/SortedMapList.js +++ b/src/observable/list/SortedMapList.js @@ -70,6 +70,10 @@ export class SortedMapList extends BaseObservableList { } onUpdate(key, value, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._sortedPairs) { + return; + } // TODO: suboptimal for performance, see above for idea with BST to speed this up if we need to const oldIdx = this._sortedPairs.findIndex(p => p.key === key); // neccesary to remove pair from array before diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index 71b7bbeb..8e936f5d 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -82,6 +82,10 @@ export class FilteredMap extends BaseObservableMap { } onUpdate(key, value, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._included) { + return; + } if (this._filter) { const wasIncluded = this._included.get(key); const isIncluded = this._filter(value, key); diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index e5d0caa7..7db04be1 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -48,6 +48,10 @@ export class JoinedMap extends BaseObservableMap { } onUpdate(source, key, value, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._subscriptions) { + return; + } if (!this._isKeyAtSourceOccluded(source, key)) { this.emitUpdate(key, value, params); } diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 48a1d1ad..ec33c4dd 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -49,6 +49,10 @@ export class MappedMap extends BaseObservableMap { } onUpdate(key, value, params) { + // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it + if (!this._mappedValues) { + return; + } const mappedValue = this._mappedValues.get(key); if (mappedValue !== undefined) { // TODO: map params somehow if needed? From 5e9ce365bf896f9471684a15183691e4e267011e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 10:27:44 +0200 Subject: [PATCH 31/73] also apply local relations when loading at top --- 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 36bcc110..b364eedb 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -164,7 +164,7 @@ export class Timeline { )); try { const entries = await readerRequest.complete(); - this._remoteEntries.setManySorted(entries); + this.addOrReplaceEntries(entries); return entries.length < amount; } finally { this._disposables.disposeTracked(readerRequest); From 2da7ef4280d7f0ac1b88eb630fc401ac721810df Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 27 May 2021 10:28:02 +0200 Subject: [PATCH 32/73] can only look in remote entries here as PEEs never return an event id --- 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 b364eedb..5ba65c4d 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -102,7 +102,7 @@ export class Timeline { if (!foundInLocalEntries && pe.relatedEventId) { // TODO: ideally iterate in reverse as target is likely to be most recent, // but not easy through ObservableList contract - for (const entry of this._allEntries) { + for (const entry of this._remoteEntries) { if (pe.relatedEventId === entry.id) { const params = updater(entry); if (params) { From 13ac41b2641d00e2540522abe60a320e9aa93bc7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 12:02:35 +0200 Subject: [PATCH 33/73] delete obsolete code --- .../ui/session/room/timeline/TimelineTile.js | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 src/platform/web/ui/session/room/timeline/TimelineTile.js diff --git a/src/platform/web/ui/session/room/timeline/TimelineTile.js b/src/platform/web/ui/session/room/timeline/TimelineTile.js deleted file mode 100644 index 9de10017..00000000 --- a/src/platform/web/ui/session/room/timeline/TimelineTile.js +++ /dev/null @@ -1,49 +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 {tag} from "../../../general/html.js"; - -export class TimelineTile { - constructor(tileVM) { - this._tileVM = tileVM; - this._root = null; - } - - root() { - return this._root; - } - - mount() { - this._root = renderTile(this._tileVM); - return this._root; - } - - unmount() {} - - update(vm, paramName) { - } -} - -function renderTile(tile) { - switch (tile.shape) { - case "message": - return tag.li([tag.strong(tile.internalId+" "), tile.label]); - case "announcement": - return tag.li([tag.strong(tile.internalId+" "), tile.announcement]); - default: - return tag.li([tag.strong(tile.internalId+" "), "unknown tile shape: " + tile.shape]); - } -} From bbf9832d6aa789aad65b9179933b97cd6a7c1474 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 12:02:55 +0200 Subject: [PATCH 34/73] switch timeline messages to css grid, and add menu button --- .../web/ui/css/themes/element/theme.css | 163 +------------ .../web/ui/css/themes/element/timeline.css | 218 ++++++++++++++++++ src/platform/web/ui/css/timeline.css | 5 - .../ui/session/room/timeline/BaseMediaView.js | 4 +- .../web/ui/session/room/timeline/FileView.js | 2 +- .../room/timeline/MissingAttachmentView.js | 2 +- .../ui/session/room/timeline/RedactedView.js | 2 +- .../session/room/timeline/TextMessageView.js | 3 +- .../web/ui/session/room/timeline/common.js | 19 +- 9 files changed, 233 insertions(+), 185 deletions(-) create mode 100644 src/platform/web/ui/css/themes/element/timeline.css diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index c6c18445..ac197f1d 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -16,6 +16,7 @@ limitations under the License. */ @import url('inter.css'); +@import url('timeline.css'); :root { font-size: 10px; @@ -508,168 +509,6 @@ a { background-color: #E3E8F0; } -ul.Timeline > li:not(.continuation) { - margin-top: 7px; -} - -ul.Timeline > li.continuation .profile { - display: none; -} - -ul.Timeline > li.continuation time { - display: none; -} - -ul.Timeline > li.messageStatus .message-container > p { - font-style: italic; - color: #777; -} - -.message-container { - padding: 1px 10px 0px 10px; - margin: 5px 10px 0 10px; - /* so the .media can grow horizontally and its spacer can grow vertically */ - width: 100%; -} - -.message-container .profile { - display: flex; - align-items: center; -} - -.TextMessageView { - width: 100%; -} - -.TextMessageView.continuation .message-container { - margin-top: 0; - margin-bottom: 0; -} - -.message-container .sender { - margin: 6px 0; - margin-left: 6px; - font-weight: bold; - line-height: 1.7rem; -} - -.hydrogen .sender.usercolor1 { color: var(--usercolor1); } -.hydrogen .sender.usercolor2 { color: var(--usercolor2); } -.hydrogen .sender.usercolor3 { color: var(--usercolor3); } -.hydrogen .sender.usercolor4 { color: var(--usercolor4); } -.hydrogen .sender.usercolor5 { color: var(--usercolor5); } -.hydrogen .sender.usercolor6 { color: var(--usercolor6); } -.hydrogen .sender.usercolor7 { color: var(--usercolor7); } -.hydrogen .sender.usercolor8 { color: var(--usercolor8); } - -.message-container time { - padding: 2px 0 0px 10px; - font-size: 0.8em; - line-height: normal; - color: #aaa; -} - - -.message-container .media { - display: grid; - margin-top: 4px; - width: 100%; -} - - -.message-container .media > a { - text-decoration: none; - width: 100%; - display: block; -} - -/* .spacer grows with an inline padding-top to the size of the image, -so the timeline doesn't jump when the image loads */ -.message-container .media > * { - grid-row: 1; - grid-column: 1; -} - -.message-container .media img, .message-container .media video { - width: 100%; - height: auto; - /* for IE11 to still scale even though the spacer is too tall */ - align-self: start; - border-radius: 4px; - display: block; -} -/* stretch the image (to the spacer) on platforms -where we can trust the spacer to always have the correct height, -otherwise the image starts with height 0 and with loading=lazy -only loads when the top comes into view*/ -.hydrogen:not(.legacy) .message-container .media img, -.hydrogen:not(.legacy) .message-container .media video { - align-self: stretch; -} - -.message-container .media > .sendStatus { - align-self: end; - justify-self: start; - font-size: 0.8em; -} - -.message-container .media > progress { - align-self: center; - justify-self: center; - width: 75%; -} - -.message-container .media > time { - align-self: end; - justify-self: end; -} - -.message-container .media > time, -.message-container .media > .sendStatus { - color: #2e2f32; - display: block; - padding: 2px; - margin: 4px; - background-color: rgba(255, 255, 255, 0.75); - border-radius: 4px; -} -.message-container .media > .spacer { - /* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */ - width: 100%; - /* don't stretch height as it is a spacer, just in case it doesn't match with image height */ - align-self: start; -} - -.TextMessageView.unsent .message-container { - color: #ccc; -} - -.TextMessageView.unverified .message-container { - color: #ff4b55; -} - -.message-container p { - margin: 3px 0; - line-height: 2.2rem; -} - -.AnnouncementView { - margin: 5px 0; - padding: 5px 10%; -} - -.AnnouncementView > div { - margin: 0 auto; - padding: 10px 20px; - background-color: rgba(245, 245, 245, 0.90); - text-align: center; - border-radius: 10px; -} - -.GapView > :not(:first-child) { - margin-left: 12px; -} - .SettingsBody { padding: 0px 16px; } diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css new file mode 100644 index 00000000..666e0ecd --- /dev/null +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -0,0 +1,218 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +ul.Timeline > li.messageStatus .Timeline_messageBody > p { + font-style: italic; + color: #777; +} + +.Timeline_message { + display: grid; + grid-template: + "avatar sender" auto + "avatar body" auto + "time body" 1fr / + 30px 1fr; + column-gap: 8px; + padding: 4px; + margin: 0 12px; + /* TODO: check whether this is needed for .media to maintain aspect ratio (on IE11) like the 100% above */ + /* width: 100%; */ + box-sizing: border-box; +} + +.Timeline_message:not(.continuation) { + margin-top: 4px; +} + +@media screen and (max-width: 800px) { + .Timeline_message { + grid-template: + "avatar sender" auto + "body body" 1fr + "time time" auto / + 30px 1fr; + } + + .Timeline_messageSender { + margin-top: 0 !important; + align-self: center; + } +} + +.Timeline_message:hover, .Timeline_message.selected { + background-color: rgba(141, 151, 165, 0.1); + border-radius: 4px; +} + +.Timeline_message:hover > .Timeline_messageOptions { + display: block; +} + +.Timeline_messageAvatar { + grid-area: avatar; +} + +.Timeline_messageSender { + grid-area: sender; + font-weight: bold; + line-height: 1.7rem; +} + +.Timeline_messageSender, .Timeline_messageBody { + /* reset body margin */ + margin: 0; +} + +.Timeline_message:not(.continuation) .Timeline_messageSender, +.Timeline_message:not(.continuation) .Timeline_messageBody { + margin-top: 4px; +} + +.Timeline_messageOptions { + display: none; + grid-area: body; + align-self: start; + justify-self: right; + margin-top: -12px; + margin-right: 4px; +} + +.Timeline_messageTime { + grid-area: time; +} + +.Timeline_messageBody time { + padding: 2px 0 0px 10px; +} + +.Timeline_messageBody time, .Timeline_messageTime { + font-size: 0.8em; + line-height: normal; + color: #aaa; +} + +.Timeline_messageBody { + grid-area: body; + line-height: 2.2rem; + /* so the .media can grow horizontally and its spacer can grow vertically */ + width: 100%; +} + +.hydrogen .Timeline_messageSender.usercolor1 { color: var(--usercolor1); } +.hydrogen .Timeline_messageSender.usercolor2 { color: var(--usercolor2); } +.hydrogen .Timeline_messageSender.usercolor3 { color: var(--usercolor3); } +.hydrogen .Timeline_messageSender.usercolor4 { color: var(--usercolor4); } +.hydrogen .Timeline_messageSender.usercolor5 { color: var(--usercolor5); } +.hydrogen .Timeline_messageSender.usercolor6 { color: var(--usercolor6); } +.hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); } +.hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); } + + +.Timeline_messageBody .media { + display: grid; + margin-top: 4px; + width: 100%; +} + +.Timeline_messageBody .media > a { + text-decoration: none; + width: 100%; + display: block; +} + +/* .spacer grows with an inline padding-top to the size of the image, +so the timeline doesn't jump when the image loads */ +.Timeline_messageBody .media > * { + grid-row: 1; + grid-column: 1; +} + +.Timeline_messageBody .media img, .Timeline_messageBody .media video { + width: 100%; + height: auto; + /* for IE11 to still scale even though the spacer is too tall */ + align-self: start; + border-radius: 4px; + display: block; +} +/* stretch the image (to the spacer) on platforms +where we can trust the spacer to always have the correct height, +otherwise the image starts with height 0 and with loading=lazy +only loads when the top comes into view*/ +.hydrogen:not(.legacy) .Timeline_messageBody .media img, +.hydrogen:not(.legacy) .Timeline_messageBody .media video { + align-self: stretch; +} + +.Timeline_messageBody .media > .sendStatus { + align-self: end; + justify-self: start; + font-size: 0.8em; +} + +.Timeline_messageBody .media > progress { + align-self: center; + justify-self: center; + width: 75%; +} + +.Timeline_messageBody .media > time { + align-self: end; + justify-self: end; +} + +.Timeline_messageBody .media > time, +.Timeline_messageBody .media > .sendStatus { + color: #2e2f32; + display: block; + padding: 2px; + margin: 4px; + background-color: rgba(255, 255, 255, 0.75); + border-radius: 4px; +} +.Timeline_messageBody .media > .spacer { + /* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */ + width: 100%; + /* don't stretch height as it is a spacer, just in case it doesn't match with image height */ + align-self: start; +} + +.Timeline_messageBody.unsent .Timeline_messageBody { + color: #ccc; +} + +.Timeline_messageBody.unverified .Timeline_messageBody { + color: #ff4b55; +} + +.AnnouncementView { + margin: 5px 0; + padding: 5px 10%; +} + +.AnnouncementView > div { + margin: 0 auto; + padding: 10px 20px; + background-color: rgba(245, 245, 245, 0.90); + text-align: center; + border-radius: 10px; +} + +.GapView > :not(:first-child) { + margin-left: 12px; +} diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 5d082c08..60af23d9 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -43,11 +43,6 @@ limitations under the License. display: block; } -.TextMessageView { - display: flex; - min-width: 0; -} - .AnnouncementView { display: flex; align-items: center; diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index b5b0ed4b..2b6b07e1 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -52,9 +52,9 @@ export class BaseMediaView extends TemplateView { }); children.push(sendStatus, progress); } - return renderMessage(t, vm, [ + return renderMessage(t, vm, t.div({className: "Timeline_messageBody"}, [ t.div({className: "media", style: `max-width: ${vm.width}px`}, children), t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) - ]); + ])); } } diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index 62760b3e..aecca858 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -26,7 +26,7 @@ export class FileView extends TemplateView { t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), ])); } else { - return renderMessage(t, vm, t.p([ + return renderMessage(t, vm, t.p({className: "Timeline_messageBody"}, [ t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), t.time(vm.date + " " + vm.time) ])); diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js index 8df90131..8afdd6d4 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -20,6 +20,6 @@ import {renderMessage} from "./common.js"; export class MissingAttachmentView extends TemplateView { render(t, vm) { const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); - return renderMessage(t, vm, t.p([vm.label, " ", remove])); + return renderMessage(t, vm, t.p({className: "Timeline_messageBody"}, [vm.label, " ", remove])); } } diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index 6e9bf105..cdc775b4 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -21,7 +21,7 @@ export class RedactedView extends TemplateView { render(t, vm) { const cancelButton = t.if(vm => vm.isRedacting, t => t.button({onClick: () => vm.abortPendingRedaction()}, "Cancel")); return renderMessage(t, vm, - [t.p([vm => vm.description, " ", cancelButton, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] + t.p({className: "Timeline_messageBody"}, [vm => vm.description, " ", cancelButton]) ); } } \ No newline at end of file diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 6a3b8ed5..b1e7d13d 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -22,9 +22,8 @@ import {renderMessage} from "./common.js"; export class TextMessageView extends TemplateView { render(t, vm) { const bodyView = t.mapView(vm => vm.body, body => new BodyView(body)); - const redactButton = t.button({onClick: () => vm.redact()}, "Redact"); return renderMessage(t, vm, - [t.p([bodyView, redactButton, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] + t.p({className: "Timeline_messageBody"}, [bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)]) ); } } diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 3b53245e..e938eb63 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -17,23 +17,20 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; -export function renderMessage(t, vm, children) { +export function renderMessage(t, vm, body) { const classes = { - "TextMessageView": true, + "Timeline_message": true, own: vm.isOwn, unsent: vm.isUnsent, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file" || vm.shape === "redacted", }; - - const profile = t.div({className: "profile"}, [ - renderStaticAvatar(vm, 30), - t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.displayName) + return t.li({className: classes}, [ + t.if(vm => !vm.isContinuation, t => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), + t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), + body, + // should be after body as it is overlayed on top + t.button({className: "Timeline_messageOptions"}, "⋮"), ]); - children = [profile].concat(children); - return t.li( - {className: classes}, - t.div({className: "message-container"}, children) - ); } From f82e873da81febaf56976bcf7804178db092f9cb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 12:17:59 +0200 Subject: [PATCH 35/73] adjust message status styling to css grid changes --- src/platform/web/ui/css/themes/element/timeline.css | 10 +++++----- src/platform/web/ui/session/room/timeline/FileView.js | 2 +- .../ui/session/room/timeline/MissingAttachmentView.js | 2 +- .../web/ui/session/room/timeline/RedactedView.js | 2 +- .../web/ui/session/room/timeline/TextMessageView.js | 10 +++++++--- src/platform/web/ui/session/room/timeline/common.js | 1 - 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 666e0ecd..14b55caf 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -15,11 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -ul.Timeline > li.messageStatus .Timeline_messageBody > p { - font-style: italic; - color: #777; -} - .Timeline_message { display: grid; grid-template: @@ -106,6 +101,11 @@ ul.Timeline > li.messageStatus .Timeline_messageBody > p { color: #aaa; } +.Timeline_messageBody.statusMessage { + font-style: italic; + color: #777; +} + .Timeline_messageBody { grid-area: body; line-height: 2.2rem; diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index aecca858..b45bd011 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -26,7 +26,7 @@ export class FileView extends TemplateView { t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), ])); } else { - return renderMessage(t, vm, t.p({className: "Timeline_messageBody"}, [ + return renderMessage(t, vm, t.p({className: "Timeline_messageBody statusMessage"}, [ t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), t.time(vm.date + " " + vm.time) ])); diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js index 8afdd6d4..600f9c3c 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -20,6 +20,6 @@ import {renderMessage} from "./common.js"; export class MissingAttachmentView extends TemplateView { render(t, vm) { const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); - return renderMessage(t, vm, t.p({className: "Timeline_messageBody"}, [vm.label, " ", remove])); + return renderMessage(t, vm, t.p({className: "Timeline_messageBody statusMessage"}, [vm.label, " ", remove])); } } diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index cdc775b4..0d6e9696 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -21,7 +21,7 @@ export class RedactedView extends TemplateView { render(t, vm) { const cancelButton = t.if(vm => vm.isRedacting, t => t.button({onClick: () => vm.abortPendingRedaction()}, "Cancel")); return renderMessage(t, vm, - t.p({className: "Timeline_messageBody"}, [vm => vm.description, " ", cancelButton]) + t.p({className: "Timeline_messageBody statusMessage"}, [vm => vm.description, " ", cancelButton]) ); } } \ No newline at end of file diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index b1e7d13d..6fd7fdb8 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -22,9 +22,13 @@ import {renderMessage} from "./common.js"; export class TextMessageView extends TemplateView { render(t, vm) { const bodyView = t.mapView(vm => vm.body, body => new BodyView(body)); - return renderMessage(t, vm, - t.p({className: "Timeline_messageBody"}, [bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)]) - ); + return renderMessage(t, vm, t.p({ + className: "Timeline_messageBody", + statusMessage: vm => vm.shape === "message-status" + }, [ + bodyView, + t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time) + ])); } } diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index e938eb63..0ec0d0cc 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -24,7 +24,6 @@ export function renderMessage(t, vm, body) { unsent: vm.isUnsent, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file" || vm.shape === "redacted", }; return t.li({className: classes}, [ t.if(vm => !vm.isContinuation, t => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), From 100e056d55ca18d09cd40154e219fcce8f665c2f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 12:22:47 +0200 Subject: [PATCH 36/73] style the button --- src/platform/web/ui/css/themes/element/timeline.css | 5 +++++ src/platform/web/ui/session/room/timeline/common.js | 2 +- 2 files changed, 6 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 14b55caf..c90d6546 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -85,6 +85,11 @@ limitations under the License. justify-self: right; margin-top: -12px; margin-right: 4px; + /* button visuals */ + border: #ccc 1px solid; + height: 24px; + background-color: #fff; + border-radius: 4px; } .Timeline_messageTime { diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 0ec0d0cc..79fd6c7f 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -30,6 +30,6 @@ export function renderMessage(t, vm, body) { t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), body, // should be after body as it is overlayed on top - t.button({className: "Timeline_messageOptions"}, "⋮"), + t.button({className: "Timeline_messageOptions"}, "⋯"), ]); } From 63e948fc80dcd752713e08f6e6bd7009e9989d24 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 12:31:15 +0200 Subject: [PATCH 37/73] change renderMessage fn to base class as preparation to create menu items in subclasses --- .../ui/session/room/timeline/BaseMediaView.js | 11 +++-- .../session/room/timeline/BaseMessageView.js | 42 +++++++++++++++++++ .../web/ui/session/room/timeline/FileView.js | 15 ++++--- .../room/timeline/MissingAttachmentView.js | 9 ++-- .../ui/session/room/timeline/RedactedView.js | 11 ++--- .../session/room/timeline/TextMessageView.js | 14 +++---- .../web/ui/session/room/timeline/common.js | 35 ---------------- 7 files changed, 68 insertions(+), 69 deletions(-) create mode 100644 src/platform/web/ui/session/room/timeline/BaseMessageView.js delete mode 100644 src/platform/web/ui/session/room/timeline/common.js diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index 2b6b07e1..80c37b81 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; -import {renderMessage} from "./common.js"; +import {BaseMessageView} from "./BaseMessageView.js"; -export class BaseMediaView extends TemplateView { - render(t, vm) { +export class BaseMediaView extends BaseMessageView { + renderMessageBody(t, vm) { const heightRatioPercent = (vm.height / vm.width) * 100; let spacerStyle = `padding-top: ${heightRatioPercent}%;`; if (vm.platform.isIE11) { @@ -52,9 +51,9 @@ export class BaseMediaView extends TemplateView { }); children.push(sendStatus, progress); } - return renderMessage(t, vm, t.div({className: "Timeline_messageBody"}, [ + return t.div({className: "Timeline_messageBody"}, [ t.div({className: "media", style: `max-width: ${vm.width}px`}, children), t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) - ])); + ]); } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js new file mode 100644 index 00000000..d95ebd90 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -0,0 +1,42 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {renderStaticAvatar} from "../../../avatar.js"; +import {TemplateView} from "../../../general/TemplateView.js"; + +export class BaseMessageView extends TemplateView { + render(t, vm) { + const classes = { + "Timeline_message": true, + own: vm.isOwn, + unsent: vm.isUnsent, + unverified: vm.isUnverified, + continuation: vm => vm.isContinuation, + }; + return t.li({className: classes}, [ + t.if(vm => !vm.isContinuation, t => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), + t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), + this.renderMessageBody(t, vm), + // should be after body as it is overlayed on top + t.button({className: "Timeline_messageOptions"}, "⋯"), + ]); + } + + renderMessageBody() { + + } +} diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index b45bd011..674f2d23 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -14,22 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; -import {renderMessage} from "./common.js"; +import {BaseMessageView} from "./BaseMessageView.js"; -export class FileView extends TemplateView { - render(t, vm) { +export class FileView extends BaseMessageView { + renderMessageBody(t, vm) { if (vm.isPending) { - return renderMessage(t, vm, t.p([ + return t.p([ vm => vm.label, " ", t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), - ])); + ]); } else { - return renderMessage(t, vm, t.p({className: "Timeline_messageBody statusMessage"}, [ + return t.p({className: "Timeline_messageBody statusMessage"}, [ t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), t.time(vm.date + " " + vm.time) - ])); + ]); } } } diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js index 600f9c3c..08d41b0e 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; -import {renderMessage} from "./common.js"; +import {BaseMessageView} from "./BaseMessageView.js"; -export class MissingAttachmentView extends TemplateView { - render(t, vm) { +export class MissingAttachmentView extends BaseMessageView { + renderMessageBody(t, vm) { const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); - return renderMessage(t, vm, t.p({className: "Timeline_messageBody statusMessage"}, [vm.label, " ", remove])); + return t.p({className: "Timeline_messageBody statusMessage"}, [vm.label, " ", remove]); } } diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index 0d6e9696..bd485b58 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -14,14 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; -import {renderMessage} from "./common.js"; +import {BaseMessageView} from "./BaseMessageView.js"; -export class RedactedView extends TemplateView { - render(t, vm) { +export class RedactedView extends BaseMessageView { + renderMessageBody(t, vm) { const cancelButton = t.if(vm => vm.isRedacting, t => t.button({onClick: () => vm.abortPendingRedaction()}, "Cancel")); - return renderMessage(t, vm, - t.p({className: "Timeline_messageBody statusMessage"}, [vm => vm.description, " ", cancelButton]) - ); + return t.p({className: "Timeline_messageBody statusMessage"}, [vm => vm.description, " ", cancelButton]); } } \ No newline at end of file diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 6fd7fdb8..d0aae8f3 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -14,21 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; import {StaticView} from "../../../general/StaticView.js"; import {tag, text} from "../../../general/html.js"; -import {renderMessage} from "./common.js"; +import {BaseMessageView} from "./BaseMessageView.js"; -export class TextMessageView extends TemplateView { - render(t, vm) { - const bodyView = t.mapView(vm => vm.body, body => new BodyView(body)); - return renderMessage(t, vm, t.p({ +export class TextMessageView extends BaseMessageView { + renderMessageBody(t, vm) { + return t.p({ className: "Timeline_messageBody", statusMessage: vm => vm.shape === "message-status" }, [ - bodyView, + t.mapView(vm => vm.body, body => new BodyView(body)), t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time) - ])); + ]); } } diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js deleted file mode 100644 index 79fd6c7f..00000000 --- a/src/platform/web/ui/session/room/timeline/common.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2020 Bruno Windels -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {renderStaticAvatar} from "../../../avatar.js"; - -export function renderMessage(t, vm, body) { - const classes = { - "Timeline_message": true, - own: vm.isOwn, - unsent: vm.isUnsent, - unverified: vm.isUnverified, - continuation: vm => vm.isContinuation, - }; - return t.li({className: classes}, [ - t.if(vm => !vm.isContinuation, t => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), - t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), - body, - // should be after body as it is overlayed on top - t.button({className: "Timeline_messageOptions"}, "⋯"), - ]); -} From 2b0fa22c8a362e25ff3adf99ea8078d906a0f5ff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 13:14:55 +0200 Subject: [PATCH 38/73] open menu when clicking ... button on message with delete/cancel option --- .../web/ui/css/themes/element/timeline.css | 5 +- src/platform/web/ui/general/Popup.js | 6 ++- .../web/ui/session/room/TimelineList.js | 1 + .../session/room/timeline/AnnouncementView.js | 3 ++ .../session/room/timeline/BaseMessageView.js | 50 ++++++++++++++++++- .../ui/session/room/timeline/RedactedView.js | 12 ++++- 6 files changed, 70 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 c90d6546..82f1c3eb 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -49,12 +49,13 @@ limitations under the License. } } -.Timeline_message:hover, .Timeline_message.selected { +.Timeline_message:hover, .Timeline_message.selected, .Timeline_message.menuOpen { background-color: rgba(141, 151, 165, 0.1); border-radius: 4px; } -.Timeline_message:hover > .Timeline_messageOptions { +.Timeline_message:hover > .Timeline_messageOptions, +.Timeline_message.menuOpen > .Timeline_messageOptions { display: block; } diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index b927b44b..b5df3a23 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -30,13 +30,14 @@ const VerticalAxis = { }; export class Popup { - constructor(view) { + constructor(view, closeCallback = null) { this._view = view; this._target = null; this._arrangement = null; this._scroller = null; this._fakeRoot = null; this._trackingTemplateView = null; + this._closeCallback = closeCallback; } trackInTemplateView(templateView) { @@ -82,6 +83,9 @@ export class Popup { document.body.removeEventListener("click", this, false); this._popup.remove(); this._view = null; + if (this._closeCallback) { + this._closeCallback(); + } } } diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 81763c05..a0ad1c83 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -45,6 +45,7 @@ export class TimelineList extends ListView { const options = { className: "Timeline bottom-aligned-scroll", list: viewModel.tiles, + onItemClick: (tileView, evt) => tileView.onClick(evt), } super(options, entry => { const View = viewClassForEntry(entry); diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 43eb91ea..2dd58b32 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -20,4 +20,7 @@ export class AnnouncementView extends TemplateView { render(t) { return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index d95ebd90..b110a9aa 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -17,8 +17,15 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; import {TemplateView} from "../../../general/TemplateView.js"; +import {Popup} from "../../../general/Popup.js"; +import {Menu} from "../../../general/Menu.js"; export class BaseMessageView extends TemplateView { + constructor(value) { + super(value); + this._menuPopup = null; + } + render(t, vm) { const classes = { "Timeline_message": true, @@ -36,7 +43,46 @@ export class BaseMessageView extends TemplateView { ]); } - renderMessageBody() { - + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick(evt) { + if (evt.target.className === "Timeline_messageOptions") { + this._toggleMenu(evt.target); + } } + + _toggleMenu(button) { + if (this._menuPopup && this._menuPopup.isOpen) { + this._menuPopup.close(); + } else { + const options = this.createMenuOptions(this.value); + if (!options.length) { + return; + } + this.root().classList.add("menuOpen"); + this._menuPopup = new Popup(new Menu(options), () => this.root().classList.remove("menuOpen")); + this._menuPopup.trackInTemplateView(this); + this._menuPopup.showRelativeTo(button, { + horizontal: { + relativeTo: "end", + align: "start", + after: 0 + }, + vertical: { + relativeTo: "start", + align: "end", + before: -24 + } + }); + } + } + + createMenuOptions(vm) { + const options = []; + if (vm.shape !== "redacted") { + options.push(Menu.option(vm.i18n`Delete`, () => vm.redact())); + } + return options; + } + + renderMessageBody() {} } diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index bd485b58..14d27d24 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -15,10 +15,18 @@ limitations under the License. */ import {BaseMessageView} from "./BaseMessageView.js"; +import {Menu} from "../../../general/Menu.js"; export class RedactedView extends BaseMessageView { renderMessageBody(t, vm) { - const cancelButton = t.if(vm => vm.isRedacting, t => t.button({onClick: () => vm.abortPendingRedaction()}, "Cancel")); - return t.p({className: "Timeline_messageBody statusMessage"}, [vm => vm.description, " ", cancelButton]); + return t.p({className: "Timeline_messageBody statusMessage"}, vm => vm.description); + } + + createMenuOptions(vm) { + const options = super.createMenuOptions(vm); + if (vm.isRedacting) { + options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortPendingRedaction())); + } + return options; } } \ No newline at end of file From 43c082475b20e1a37a5f4605201145b2e66ed157 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 15:27:02 +0200 Subject: [PATCH 39/73] unify cancel option for various tiles in menu option --- .../web/ui/session/room/timeline/BaseMediaView.js | 3 +-- .../web/ui/session/room/timeline/BaseMessageView.js | 7 +++++-- .../web/ui/session/room/timeline/FileView.js | 12 +++++------- .../session/room/timeline/MissingAttachmentView.js | 3 +-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index 80c37b81..c52fbaed 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -36,13 +36,12 @@ export class BaseMediaView extends BaseMessageView { t.time(vm.date + " " + vm.time), ]; if (vm.isPending) { - const cancel = t.button({onClick: () => vm.abortSending(), className: "link"}, vm.i18n`Cancel`); const sendStatus = t.div({ className: { sendStatus: true, hidden: vm => !vm.sendStatus }, - }, [vm => vm.sendStatus, " ", cancel]); + }, vm => vm.sendStatus); const progress = t.progress({ min: 0, max: 100, diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index b110a9aa..dd7791c8 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -59,7 +59,8 @@ export class BaseMessageView extends TemplateView { return; } this.root().classList.add("menuOpen"); - this._menuPopup = new Popup(new Menu(options), () => this.root().classList.remove("menuOpen")); + const onClose = () => this.root().classList.remove("menuOpen"); + this._menuPopup = new Popup(new Menu(options), onClose); this._menuPopup.trackInTemplateView(this); this._menuPopup.showRelativeTo(button, { horizontal: { @@ -78,7 +79,9 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; - if (vm.shape !== "redacted") { + if (vm.isPending) { + options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); + } else if (vm.shape !== "redacted") { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact())); } return options; diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index 674f2d23..6a2d418e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -18,17 +18,15 @@ import {BaseMessageView} from "./BaseMessageView.js"; export class FileView extends BaseMessageView { renderMessageBody(t, vm) { + const children = []; if (vm.isPending) { - return t.p([ - vm => vm.label, - " ", - t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), - ]); + children.push(vm => vm.label); } else { - return t.p({className: "Timeline_messageBody statusMessage"}, [ + children.push( t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), t.time(vm.date + " " + vm.time) - ]); + ); } + return t.p({className: "Timeline_messageBody statusMessage"}, children); } } diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js index 08d41b0e..473a1acc 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -18,7 +18,6 @@ import {BaseMessageView} from "./BaseMessageView.js"; export class MissingAttachmentView extends BaseMessageView { renderMessageBody(t, vm) { - const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); - return t.p({className: "Timeline_messageBody statusMessage"}, [vm.label, " ", remove]); + return t.p({className: "Timeline_messageBody statusMessage"}, vm.label); } } From 5afcfc3e9b81e361fcc7d82ec3f0e0e31dce6d99 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 15:27:25 +0200 Subject: [PATCH 40/73] fix unsent/unverified message style --- src/platform/web/ui/css/themes/element/timeline.css | 4 ++-- 1 file changed, 2 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 82f1c3eb..b217cc82 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -198,11 +198,11 @@ only loads when the top comes into view*/ align-self: start; } -.Timeline_messageBody.unsent .Timeline_messageBody { +.Timeline_message.unsent .Timeline_messageBody { color: #ccc; } -.Timeline_messageBody.unverified .Timeline_messageBody { +.Timeline_message.unverified .Timeline_messageBody { color: #ff4b55; } From b3749f2d925147f9e73afbe907da9af1306a77ce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 15:27:44 +0200 Subject: [PATCH 41/73] prevent long links from creating horizontal scroll --- src/platform/web/ui/css/themes/element/timeline.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index b217cc82..cd0d4b62 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -222,3 +222,7 @@ only loads when the top comes into view*/ .GapView > :not(:first-child) { margin-left: 12px; } + +.Timeline_messageBody a { + word-break: break-all; +} \ No newline at end of file From 7f419936489a8e60c0f9a7f3ec946c829cdd856b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 15:28:04 +0200 Subject: [PATCH 42/73] prevent buttons with negative margin to displace message menu --- src/platform/web/ui/general/Popup.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index b5df3a23..ac5e3160 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -104,9 +104,10 @@ export class Popup { _onScroll() { if (this._scroller && !this._isVisibleInScrollParent(VerticalAxis)) { this.close(); + } else { + this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); + this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); } - this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); - this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); } _onClick() { @@ -190,7 +191,15 @@ function findScrollParent(el) { do { parent = parent.parentElement; if (parent.scrollHeight > parent.clientHeight) { - return parent; + // double check that overflow would allow a scrollbar + // because some elements, like a button with negative margin to increate the click target + // can cause the scrollHeight to be larger than the clientHeight in the parent + // see button.link class + const style = window.getComputedStyle(parent); + const {overflow} = style; + if (overflow === "auto" || overflow === "scroll") { + return parent; + } } } while (parent !== el.offsetParent); } From 57d9916746e2e6b39253fe5dbed3339db885c5b3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 15:30:03 +0200 Subject: [PATCH 43/73] buttons in ff were not in Inter --- src/platform/web/ui/css/themes/element/theme.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index ac197f1d..93ba036d 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -38,6 +38,10 @@ limitations under the License. --usercolor8: #74D12C; } +.hydrogen button { + font-family: inherit; +} + .avatar { border-radius: 100%; background: #fff; From 95a680eb83b66030c51cb511e21fa205ff183e12 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 16:24:47 +0200 Subject: [PATCH 44/73] fix whitespace --- .../web/ui/session/room/timeline/RedactedView.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index 14d27d24..e6f9b4d9 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -23,10 +23,10 @@ export class RedactedView extends BaseMessageView { } createMenuOptions(vm) { - const options = super.createMenuOptions(vm); - if (vm.isRedacting) { - options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortPendingRedaction())); - } - return options; + const options = super.createMenuOptions(vm); + if (vm.isRedacting) { + options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortPendingRedaction())); + } + return options; } } \ No newline at end of file From 5b0675b711cddddb0de5669d7ab2332afa19c261 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 16:24:55 +0200 Subject: [PATCH 45/73] fix lint --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 2 +- src/platform/web/ui/session/room/timeline/RedactedView.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index dd7791c8..5e069dad 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -35,7 +35,7 @@ export class BaseMessageView extends TemplateView { continuation: vm => vm.isContinuation, }; return t.li({className: classes}, [ - t.if(vm => !vm.isContinuation, t => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), + t.if(vm => !vm.isContinuation, () => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), this.renderMessageBody(t, vm), // should be after body as it is overlayed on top diff --git a/src/platform/web/ui/session/room/timeline/RedactedView.js b/src/platform/web/ui/session/room/timeline/RedactedView.js index e6f9b4d9..ec30c1e7 100644 --- a/src/platform/web/ui/session/room/timeline/RedactedView.js +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -18,7 +18,7 @@ import {BaseMessageView} from "./BaseMessageView.js"; import {Menu} from "../../../general/Menu.js"; export class RedactedView extends BaseMessageView { - renderMessageBody(t, vm) { + renderMessageBody(t) { return t.p({className: "Timeline_messageBody statusMessage"}, vm => vm.description); } From 6a5d856093bce8fb8b33abedddafeee82f0fcc2f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 28 May 2021 16:25:23 +0200 Subject: [PATCH 46/73] add destructive flag to delete menu option --- src/platform/web/ui/css/themes/element/theme.css | 10 ++-------- src/platform/web/ui/general/Menu.js | 15 ++++++++++++++- .../ui/session/room/timeline/BaseMessageView.js | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 93ba036d..9ef124aa 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -671,14 +671,8 @@ button.link { padding: 8px 32px 8px 8px; } -.menu button:focus { - background-color: #03B381; - color: white; -} - -.menu button:hover { - background-color: #03B381; - color: white; +.menu .destructive button { + color: #FF4B55; } .InviteView_body { diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index 10c5f07e..2fed5e2d 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -28,8 +28,15 @@ 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: o.icon ? `icon ${o.icon}` : "", + className, }, t.button({onClick: o.callback}, o.label)); })); } @@ -40,10 +47,16 @@ class MenuOption { this.label = label; this.callback = callback; this.icon = null; + this.destructive = false; } setIcon(className) { this.icon = className; return this; } + + setDestructive() { + this.destructive = true; + return this; + } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 5e069dad..b90c89b8 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -82,7 +82,7 @@ export class BaseMessageView extends TemplateView { if (vm.isPending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.shape !== "redacted") { - options.push(Menu.option(vm.i18n`Delete`, () => vm.redact())); + options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options; } From fa37e8fedbcbe973172f6f78bb2994c1ea156e7d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 10:46:16 +0200 Subject: [PATCH 47/73] findAndUpdate uses predicate, just add callback to update method --- src/matrix/room/timeline/Timeline.js | 2 +- src/observable/list/SortedArray.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 5ba65c4d..dffe973b 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -122,7 +122,7 @@ export class Timeline { for (const entry of entries) { // this will use the comparator and thus // check for equality using the compare method in BaseEntry - this._remoteEntries.findAndUpdate(entry, (previousEntry, entry) => { + this._remoteEntries.update(entry, null, previousEntry => { entry.transferLocalEchoState(previousEntry); }); } diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index d2663f37..f040702d 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -57,9 +57,13 @@ export class SortedArray extends BaseObservableList { return false; } - update(item, updateParams = null) { + update(item, updateParams = null, previousCallback = null) { const idx = this.indexOf(item); if (idx !== -1) { + if (previousCallback) { + const oldItem = this._items[idx]; + previousCallback(oldItem); + } this._items[idx] = item; this.emitUpdate(idx, item, updateParams); } From 63b371b6ef990e06e2d9f75f2da3ba13b227d9a1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 10:47:32 +0200 Subject: [PATCH 48/73] support findAndUpdate with same predicate semantics in SortedArray too --- src/matrix/room/timeline/Timeline.js | 26 ++++++++++------------ src/observable/list/MappedList.js | 14 ++---------- src/observable/list/SortedArray.js | 17 +++------------ src/observable/list/common.js | 32 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 src/observable/list/common.js diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index dffe973b..6614e58b 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -90,27 +90,23 @@ export class Timeline { } _applyAndEmitLocalRelationChange(pe, updater) { - // first, look in local entries (separately, as it has its own update mechanism) + const updateOrFalse = e => { + const params = updater(e); + return params ? params : false; + }; + // first, look in local entries based on txn id const foundInLocalEntries = this._localEntries.findAndUpdate( e => e.id === pe.relatedTxnId, - e => { - const params = updater(e); - return params ? params : false; - }, + updateOrFalse, ); - // now look in remote entries + // now look in remote entries based on event id if (!foundInLocalEntries && pe.relatedEventId) { // TODO: ideally iterate in reverse as target is likely to be most recent, // but not easy through ObservableList contract - for (const entry of this._remoteEntries) { - if (pe.relatedEventId === entry.id) { - const params = updater(entry); - if (params) { - this._remoteEntries.update(entry, params); - } - return; - } - } + this._remoteEntries.findAndUpdate( + e => e.id === pe.relatedEventId, + updateOrFalse + ); } } diff --git a/src/observable/list/MappedList.js b/src/observable/list/MappedList.js index 1aed299f..ddb61384 100644 --- a/src/observable/list/MappedList.js +++ b/src/observable/list/MappedList.js @@ -16,6 +16,7 @@ limitations under the License. */ import {BaseObservableList} from "./BaseObservableList.js"; +import {findAndUpdateInArray} from "./common.js"; export class MappedList extends BaseObservableList { constructor(sourceList, mapper, updater, removeCallback) { @@ -80,18 +81,7 @@ export class MappedList extends BaseObservableList { } findAndUpdate(predicate, updater) { - const index = this._mappedValues.findIndex(predicate); - if (index !== -1) { - const mappedValue = this._mappedValues[index]; - // allow bailing out of sending an emit if updater determined its not needed - const params = updater(mappedValue); - if (params !== false) { - this.emitUpdate(index, mappedValue, params); - } - // found - return true; - } - return false; + return findAndUpdateInArray(predicate, this._mappedValues, this, updater); } get length() { diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index f040702d..cf17449b 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseObservableList} from "./BaseObservableList.js"; import {sortedIndex} from "../../utils/sortedIndex.js"; +import {findAndUpdateInArray} from "./common.js"; export class SortedArray extends BaseObservableList { constructor(comparator) { @@ -41,20 +42,8 @@ export class SortedArray extends BaseObservableList { } } - findAndUpdate(newValue, updater) { - const index = this.indexOf(newValue); - if (index !== -1) { - const oldValue = this._items[index]; - // allow bailing out of sending an emit if updater determined its not needed - const params = updater(oldValue, newValue); - if (params !== false) { - this._items[index] = newValue; - this.emitUpdate(index, newValue, params); - } - // found - return true; - } - return false; + findAndUpdate(predicate, updater) { + return findAndUpdateInArray(predicate, this._items, this, updater); } update(item, updateParams = null, previousCallback = null) { diff --git a/src/observable/list/common.js b/src/observable/list/common.js new file mode 100644 index 00000000..8dbf7873 --- /dev/null +++ b/src/observable/list/common.js @@ -0,0 +1,32 @@ +/* +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. +*/ + +/* inline update of item in collection backed by array, without replacing the preexising item */ +export function findAndUpdateInArray(predicate, array, observable, updater) { + const index = array.findIndex(predicate); + if (index !== -1) { + const value = array[index]; + // allow bailing out of sending an emit if updater determined its not needed + const params = updater(value); + if (params !== false) { + observable.emitUpdate(index, value, params); + } + // found + return true; + } + return false; +} \ No newline at end of file From 0596ca06b156722fbe455a2b10772e5d4697349f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 11:56:41 +0200 Subject: [PATCH 49/73] emit remove before linking up sibling tiles otherwise emitting the update from updatePreviousSibling has the wrong idx --- .../session/room/timeline/TilesCollection.js | 31 +++++++++++++++++-- src/observable/list/ObservableArray.js | 5 +++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index f8c55e3a..0acb2859 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -195,11 +195,14 @@ export class TilesCollection extends BaseObservableList { _removeTile(tileIdx, tile) { const prevTile = this._getTileAtIdx(tileIdx - 1); const nextTile = this._getTileAtIdx(tileIdx + 1); + // applying and emitting the remove should happen + // atomically, as updateNext/PreviousSibling might + // emit an update with the wrong index otherwise this._tiles.splice(tileIdx, 1); - prevTile && prevTile.updateNextSibling(nextTile); - nextTile && nextTile.updatePreviousSibling(prevTile); tile.dispose(); this.emitRemove(tileIdx, tile); + prevTile?.updateNextSibling(nextTile); + nextTile?.updatePreviousSibling(prevTile); } // would also be called when unloading a part of the timeline @@ -301,5 +304,29 @@ export function tests() { entries.insert(1, {n: 7}); assert(receivedAdd); }, + "emit update with correct index in updatePreviousSibling during remove": assert => { + class UpdateOnSiblingTile extends TestTile { + updatePreviousSibling() { + this.update?.(this, "previous"); + } + } + const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]); + const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry)); + const events = []; + tiles.subscribe({ + onUpdate(idx, tile) { + assert.equal(idx, 1); + assert.equal(tile.entry.n, 15); + events.push("update"); + }, + onRemove(idx, tile) { + assert.equal(idx, 1); + assert.equal(tile.entry.n, 10); + events.push("remove"); + } + }); + entries.remove(1); + assert.deepEqual(events, ["remove", "update"]); + } } } diff --git a/src/observable/list/ObservableArray.js b/src/observable/list/ObservableArray.js index ca33fcce..37802587 100644 --- a/src/observable/list/ObservableArray.js +++ b/src/observable/list/ObservableArray.js @@ -27,6 +27,11 @@ export class ObservableArray extends BaseObservableList { this.emitAdd(this._items.length - 1, item); } + remove(idx) { + const [item] = this._items.splice(idx, 1); + this.emitRemove(idx, item); + } + insertMany(idx, items) { for(let item of items) { this.insert(idx, item); From 447b98ce6ca73c85c304d4333a66184723b16b39 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 11:57:17 +0200 Subject: [PATCH 50/73] don't use subviews for showing/hiding avatar & sender on continuation --- src/platform/web/ui/general/TemplateView.js | 20 +++++++++++++++ .../session/room/timeline/BaseMessageView.js | 25 ++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index fd27eafd..f3425136 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -344,6 +344,26 @@ class TemplateBuilder { if(predicate, renderFn) { return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); } + + /** You probably are looking for something else, like map or mapView. + This is an escape hatch that allows you to do manual DOM manipulations + as a reaction to a binding change. + This should only be used if the side-effect won't add any bindings, + event handlers, ... + You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect, + instead use tags from html.js to help you construct any DOM you need. */ + mapSideEffect(mapFn, sideEffect) { + let prevValue = mapFn(this._value); + const binding = () => { + const newValue = mapFn(this._value); + if (prevValue !== newValue) { + sideEffect(newValue, prevValue); + prevValue = newValue; + } + }; + this._addBinding(binding); + sideEffect(prevValue, undefined); + } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index b90c89b8..caf9e961 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -16,6 +16,7 @@ limitations under the License. */ import {renderStaticAvatar} from "../../../avatar.js"; +import {tag} from "../../../general/html.js"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; @@ -27,20 +28,32 @@ export class BaseMessageView extends TemplateView { } render(t, vm) { - const classes = { + const li = t.li({className: { "Timeline_message": true, own: vm.isOwn, unsent: vm.isUnsent, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - }; - return t.li({className: classes}, [ - t.if(vm => !vm.isContinuation, () => renderStaticAvatar(vm, 30, "Timeline_messageAvatar")), - t.if(vm => !vm.isContinuation, t => t.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)), + }}, [ this.renderMessageBody(t, vm), // should be after body as it is overlayed on top t.button({className: "Timeline_messageOptions"}, "⋯"), - ]); + ]); + // given that there can be many tiles, we don't add + // unneeded DOM nodes in case of a continuation, and we add it + // with a side-effect binding to not have to create sub views, + // as the avatar or sender doesn't need any bindings or event handlers. + // don't use `t` from within the side-effect callback + t.mapSideEffect(vm => vm.isContinuation, (isContinuation, wasContinuation) => { + if (isContinuation && wasContinuation === false) { + li.removeChild(li.querySelector(".Timeline_messageAvatar")); + li.removeChild(li.querySelector(".Timeline_messageSender")); + } else if (!isContinuation) { + li.appendChild(renderStaticAvatar(vm, 30, "Timeline_messageAvatar")); + li.appendChild(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)); + } + }); + return li; } /* This is called by the parent ListView, which just has 1 listener for the whole list */ From 762ed96a3bd259a16f86aa6ff29fc0ab8931345a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 11:58:01 +0200 Subject: [PATCH 51/73] Not needed as both evententry and pendingevententry return timestamp --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 3e4e1569..ca15f207 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -87,8 +87,8 @@ export class BaseMessageTile extends SimpleTile { let isContinuation = false; if (prev && prev instanceof BaseMessageTile && prev.sender === this.sender) { // timestamp is null for pending events - const myTimestamp = this._entry.timestamp || this.clock.now(); - const otherTimestamp = prev._entry.timestamp || this.clock.now(); + const myTimestamp = this._entry.timestamp; + const otherTimestamp = prev._entry.timestamp; // other message was sent less than 5min ago isContinuation = (myTimestamp - otherTimestamp) < (5 * 60 * 1000); } From 2e34668b9128ab4dc0b141befffb9c45cb1e60ad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 12:28:42 +0200 Subject: [PATCH 52/73] show errors while mounting list view children --- src/platform/web/ui/general/ListView.js | 9 +++++++-- src/platform/web/ui/general/error.js | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 3ec7207c..749ae6cf 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {tag} from "./html.js"; +import {errorToDOM} from "./error.js"; function insertAt(parentNode, idx, childNode) { const isLast = idx === parentNode.childElementCount; @@ -106,8 +107,12 @@ export class ListView { for (let item of this._list) { const child = this._childCreator(item); this._childInstances.push(child); - const childDomNode = child.mount(this._mountArgs); - fragment.appendChild(childDomNode); + try { + const childDomNode = child.mount(this._mountArgs); + fragment.appendChild(childDomNode); + } catch (err) { + fragment.appendChild(errorToDOM(err)); + } } this._root.appendChild(fragment); } diff --git a/src/platform/web/ui/general/error.js b/src/platform/web/ui/general/error.js index d72275fc..48728a4b 100644 --- a/src/platform/web/ui/general/error.js +++ b/src/platform/web/ui/general/error.js @@ -18,7 +18,10 @@ import {tag} from "./html.js"; export function errorToDOM(error) { const stack = new Error().stack; - const callee = stack.split("\n")[1]; + let callee = null; + if (stack) { + callee = stack.split("\n")[1]; + } return tag.div([ tag.h2("Something went wrong…"), tag.h3(error.message), From 5f5f83912d8ebfbb20c5b8ebd932a5fe5b6f981e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:02:01 +0200 Subject: [PATCH 53/73] try see if newer autoprefixer fixes our issue(it didn't, but good still) --- yarn.lock | 80 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7cf727a4..57607f10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1015,15 +1015,15 @@ astral-regex@^2.0.0: integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== autoprefixer@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.0.1.tgz#e2d9000f84ebd98d77b7bc16f8adb2ff1f7bb946" - integrity sha512-aQo2BDIsoOdemXUAOBpFv4ZQa2DrOtEufarYhtFsK1088Ca0TUwu/aQWf0M3mrILXZ3mTIVn1lR3hPW8acacsw== + version "10.2.6" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.2.6.tgz#aadd9ec34e1c98d403e01950038049f0eb252949" + integrity sha512-8lChSmdU6dCNMCQopIf4Pe5kipkAGj/fvTMslCsih0uHpOrXOPUEVOmYMMqmw3cekQkSD7EhIeuYl5y0BLdKqg== dependencies: - browserslist "^4.14.5" - caniuse-lite "^1.0.30001137" - colorette "^1.2.1" + browserslist "^4.16.6" + caniuse-lite "^1.0.30001230" + colorette "^1.2.2" + fraction.js "^4.1.1" normalize-range "^0.1.2" - num2fraction "^1.2.2" postcss-value-parser "^4.1.0" babel-plugin-dynamic-import-node@^2.3.3: @@ -1073,15 +1073,16 @@ browserslist@^4.12.0, browserslist@^4.8.5: escalade "^3.0.2" node-releases "^1.1.60" -browserslist@^4.14.5: - version "4.14.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.5.tgz#1c751461a102ddc60e40993639b709be7f2c4015" - integrity sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA== +browserslist@^4.16.6: + version "4.16.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== dependencies: - caniuse-lite "^1.0.30001135" - electron-to-chromium "^1.3.571" - escalade "^3.1.0" - node-releases "^1.1.61" + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" + escalade "^3.1.1" + node-releases "^1.1.71" bs58@^4.0.1: version "4.0.1" @@ -1100,11 +1101,16 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -caniuse-lite@^1.0.30001111, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001137: +caniuse-lite@^1.0.30001111: version "1.0.30001187" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001187.tgz" integrity sha512-w7/EP1JRZ9552CyrThUnay2RkZ1DXxKe/Q2swTC4+LElLh9RRYrL1Z+27LlakB8kzY0fSmHw9mc7XYDUKAKWMA== +caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001230: + version "1.0.30001231" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001231.tgz#6c1f9b49fc27cc368b894e64b9b28b39ef80603b" + integrity sha512-WAFFv31GgU4DiwNAy77qMo3nNyycEhH3ikcCVHvkQpPe/fO8Tb2aRYzss8kgyLQBm8mJ7OryW4X6Y4vsBCIqag== + chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1158,10 +1164,10 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" - integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== +colorette@^1.2.1, colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== colors@^1.3.3: version "1.4.0" @@ -1351,10 +1357,10 @@ electron-to-chromium@^1.3.523: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.534.tgz#fc7af8518dd00a5b22a24aed3f116b5d097e2330" integrity sha512-7x2S3yUrspNHQOoPk+Eo+iHViSiJiEGPI6BpmLy1eT2KRNGCkBt/NUYqjfXLd1DpDCQp7n3+LfA1RkbG+LqTZQ== -electron-to-chromium@^1.3.571: - version "1.3.578" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.578.tgz#e6671936f4571a874eb26e2e833aa0b2c0b776e0" - integrity sha512-z4gU6dA1CbBJsAErW5swTGAaU2TBzc2mPAonJb00zqW1rOraDo2zfBMDRvaz9cVic+0JEZiYbHWPw/fTaZlG2Q== +electron-to-chromium@^1.3.723: + version "1.3.742" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.742.tgz#7223215acbbd3a5284962ebcb6df85d88b95f200" + integrity sha512-ihL14knI9FikJmH2XUIDdZFWJxvr14rPSdOhJ7PpS27xbz8qmaRwCwyg/bmFwjWKmWK9QyamiCZVCvXm5CH//Q== emoji-regex@^8.0.0: version "8.0.0" @@ -1392,10 +1398,10 @@ escalade@^3.0.2: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== -escalade@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.0.tgz#e8e2d7c7a8b76f6ee64c2181d6b8151441602d4e" - integrity sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig== +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-html@~1.0.3: version "1.0.3" @@ -1591,6 +1597,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +fraction.js@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.1.1.tgz#ac4e520473dae67012d618aab91eda09bcb400ff" + integrity sha512-MHOhvvxHTfRFpF1geTK9czMIZ6xclsEor2wkIGYYq+PxcQqT7vStJqjhe6S1TenZrMZzo+wlqOufBDVepUEgPg== + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -2001,10 +2012,10 @@ node-releases@^1.1.60: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== -node-releases@^1.1.61: - version "1.1.61" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e" - integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g== +node-releases@^1.1.71: + version "1.1.72" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" + integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== normalize-range@^0.1.2: version "0.1.2" @@ -2018,11 +2029,6 @@ nth-check@~1.0.1: dependencies: boolbase "~1.0.0" -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= - object-keys@^1.0.11, object-keys@^1.0.12: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" From d66cdc97cd50521d7c94aab6d88041e299be4b46 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:02:42 +0200 Subject: [PATCH 54/73] fix message options button placement in IE11 --- src/platform/web/ui/css/themes/element/timeline.css | 3 ++- 1 file changed, 2 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 cd0d4b62..21c53057 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -83,12 +83,13 @@ limitations under the License. display: none; grid-area: body; align-self: start; - justify-self: right; + justify-self: end; margin-top: -12px; margin-right: 4px; /* button visuals */ border: #ccc 1px solid; height: 24px; + width: 24px; background-color: #fff; border-radius: 4px; } From d4373eb309eff9be5570bfd3efa215cf49842fb2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:03:23 +0200 Subject: [PATCH 55/73] make options button look the same in all browsers --- src/platform/web/ui/css/themes/element/timeline.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 21c53057..d7eac940 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -92,6 +92,10 @@ limitations under the License. width: 24px; background-color: #fff; border-radius: 4px; + padding: 0; + text-align: center; + line-height: 22px; + cursor: pointer; } .Timeline_messageTime { From 57288f75b07920b560dfaabe69ecc056461178b2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:03:41 +0200 Subject: [PATCH 56/73] add avatar & sender as first element in message so they don't occlude --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index caf9e961..f15709aa 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -49,8 +49,8 @@ export class BaseMessageView extends TemplateView { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); } else if (!isContinuation) { - li.appendChild(renderStaticAvatar(vm, 30, "Timeline_messageAvatar")); - li.appendChild(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName)); + li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); + li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } }); return li; From f8f1d49c5694914eae98e711beb1f20f2659d1ba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:04:03 +0200 Subject: [PATCH 57/73] polyfill String.matchAll for IE11 See https://github.com/babel/babel/issues/10816 Don't really understand how or why this works, but it did --- scripts/build.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index fef0f0a1..b8467760 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -213,7 +213,7 @@ async function buildJsLegacy(mainFile, extraFiles, importOverrides) { "@babel/preset-env", { useBuiltIns: "entry", - corejs: "3", + corejs: "3.4", targets: "IE 11", // we provide our own promise polyfill (es6-promise) // with support for synchronous flushing of From 245b5458d08863e0bfbd61055cf46cea42d7b7e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:06:11 +0200 Subject: [PATCH 58/73] put latest version in package.json as well --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 34f08269..eb2dde61 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@rollup/plugin-commonjs": "^15.0.0", "@rollup/plugin-multi-entry": "^4.0.0", "@rollup/plugin-node-resolve": "^9.0.0", - "autoprefixer": "^10.0.1", + "autoprefixer": "^10.2.6", "cheerio": "^1.0.0-rc.3", "commander": "^6.0.0", "core-js": "^3.6.5", diff --git a/yarn.lock b/yarn.lock index 57607f10..d29a6d84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1014,7 +1014,7 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -autoprefixer@^10.0.1: +autoprefixer@^10.2.6: version "10.2.6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.2.6.tgz#aadd9ec34e1c98d403e01950038049f0eb252949" integrity sha512-8lChSmdU6dCNMCQopIf4Pe5kipkAGj/fvTMslCsih0uHpOrXOPUEVOmYMMqmw3cekQkSD7EhIeuYl5y0BLdKqg== From 128f9812a6ed9f7ab7711ddb0bfc1223009acc3f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:08:06 +0200 Subject: [PATCH 59/73] set destructive flag here too --- src/platform/web/ui/session/room/RoomView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index fff875b8..ea84678b 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,10 +68,10 @@ export class RoomView extends TemplateView { const vm = this.value; const options = []; if (vm.canLeave) { - options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom())); + options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); } if (vm.canForget) { - options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom())); + options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); } if (vm.canRejoin) { options.push(Menu.option(vm.i18n`Rejoin room`, () => vm.rejoinRoom())); From 23459aad52e8b6ff11b1d30a423d2e60222eda20 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:52:03 +0200 Subject: [PATCH 60/73] check if you are allowed to redact a message --- .../room/timeline/TimelineViewModel.js | 2 +- .../room/timeline/tiles/BaseMessageTile.js | 4 + .../session/room/timeline/tiles/SimpleTile.js | 4 + src/matrix/room/timeline/PowerLevels.js | 97 +++++++++++++++++++ src/matrix/room/timeline/Timeline.js | 28 +++++- .../storage/idb/stores/RoomStateStore.js | 15 ++- .../session/room/timeline/BaseMessageView.js | 2 +- 7 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 src/matrix/room/timeline/PowerLevels.js diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index a5dcee07..04ae7570 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -40,7 +40,7 @@ export class TimelineViewModel extends ViewModel { super(options); const {room, timeline, ownUserId} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, ownUserId}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline, ownUserId}))); } /** diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index ca15f207..8badc429 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -101,4 +101,8 @@ export class BaseMessageTile extends SimpleTile { redact(reason, log) { return this._room.sendRedaction(this._entry.id, reason, log); } + + get canRedact() { + return this._powerLevels.canRedactFromSender(this._entry.sender); + } } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 07529b51..b6c434df 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -123,4 +123,8 @@ export class SimpleTile extends ViewModel { get _room() { return this.getOption("room"); } + + get _powerLevels() { + return this.getOption("timeline").powerLevels; + } } diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js new file mode 100644 index 00000000..278c3aa4 --- /dev/null +++ b/src/matrix/room/timeline/PowerLevels.js @@ -0,0 +1,97 @@ +/* +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 PowerLevels { + constructor({powerLevelEvent, createEvent, ownUserId}) { + this._plEvent = powerLevelEvent; + this._createEvent = createEvent; + this._ownUserId = ownUserId; + } + + canRedactFromSender(userId) { + if (userId === this._ownUserId) { + return true; + } else { + return this.canRedact; + } + } + + get canRedact() { + return this._getUserLevel(this._ownUserId) >= this._getActionLevel("redact"); + } + + _getUserLevel(userId) { + if (this._plEvent) { + let userLevel = this._plEvent.content?.users?.[userId]; + if (typeof userLevel !== "number") { + userLevel = this._plEvent.content?.users_default; + } + if (typeof userLevel === "number") { + return userLevel; + } else { + return 0; + } + } else if (this._createEvent) { + if (userId === this._createEvent.content?.creator) { + return 100; + } + } + return 0; + } + + /** @param {string} action either "invite", "kick", "ban" or "redact". */ + _getActionLevel(action) { + const level = this._plEvent?.content[action]; + if (typeof level === "number") { + return level; + } else { + return 50; + } + } +} + +export function tests() { + const alice = "@alice:hs.tld"; + const bob = "@bob:hs.tld"; + const createEvent = {content: {creator: alice}}; + const powerLevelEvent = {content: { + redact: 50, + users: { + [alice]: 50 + }, + users_default: 0 + }}; + + return { + "redact somebody else event with power level event": assert => { + const pl1 = new PowerLevels({powerLevelEvent, ownUserId: alice}); + assert.equal(pl1.canRedact, true); + const pl2 = new PowerLevels({powerLevelEvent, ownUserId: bob}); + assert.equal(pl2.canRedact, false); + }, + "redact somebody else event with create event": assert => { + const pl1 = new PowerLevels({createEvent, ownUserId: alice}); + assert.equal(pl1.canRedact, true); + const pl2 = new PowerLevels({createEvent, ownUserId: bob}); + assert.equal(pl2.canRedact, false); + }, + "redact own event": assert => { + const pl = new PowerLevels({ownUserId: alice}); + assert.equal(pl.canRedactFromSender(alice), true); + assert.equal(pl.canRedactFromSender(bob), false); + }, + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 6614e58b..ce34bbf8 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -21,6 +21,7 @@ import {Direction} from "./Direction.js"; import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; +import {PowerLevels} from "./PowerLevels.js"; export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { @@ -40,11 +41,15 @@ export class Timeline { }); this._readerRequest = null; this._allEntries = null; + this._powerLevels = null; } /** @package */ async load(user, membership, log) { - const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat(this._storage.storeNames.roomMembers)); + const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat( + this._storage.storeNames.roomMembers, + this._storage.storeNames.roomState + )); const memberData = await txn.roomMembers.get(this._roomId, user.id); if (memberData) { this._ownMember = new RoomMember(memberData); @@ -66,6 +71,22 @@ export class Timeline { } finally { this._disposables.disposeTracked(readerRequest); } + this._powerLevels = await this._loadPowerLevels(txn); + } + + async _loadPowerLevels(txn) { + const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); + if (powerLevelsState) { + return new PowerLevels({ + powerLevelEvent: powerLevelsState.event, + ownUserId: this._ownMember.userId + }); + } + const createState = await txn.roomState.get(this._roomId, "m.room.create", ""); + return new PowerLevels({ + createEvent: createState.event, + ownUserId: this._ownMember.userId + }); } _setupEntries(timelineEntries) { @@ -199,7 +220,12 @@ export class Timeline { } } + /** @internal */ enableEncryption(decryptEntries) { this._timelineReader.enableEncryption(decryptEntries); } + + get powerLevels() { + return this._powerLevels; + } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 20ef6942..e4c0c894 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -17,21 +17,26 @@ limitations under the License. import {MAX_UNICODE} from "./common.js"; +function encodeKey(roomId, eventType, stateKey) { + return `${roomId}|${eventType}|${stateKey}`; +} + export class RoomStateStore { constructor(idbStore) { this._roomStateStore = idbStore; } - async getAllForType(type) { + getAllForType(roomId, type) { throw new Error("unimplemented"); } - async get(type, stateKey) { - throw new Error("unimplemented"); + get(roomId, type, stateKey) { + const key = encodeKey(roomId, type, stateKey); + return this._roomStateStore.get(key); } - async set(roomId, event) { - const key = `${roomId}|${event.type}|${event.state_key}`; + set(roomId, event) { + const key = encodeKey(roomId, event.type, event.state_key); const entry = {roomId, event, key}; return this._roomStateStore.put(entry); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index f15709aa..c9857638 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -94,7 +94,7 @@ export class BaseMessageView extends TemplateView { const options = []; if (vm.isPending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); - } else if (vm.shape !== "redacted") { + } else if (vm.shape !== "redacted" && vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options; From 606d40c9d41bca829379c61fb7efc46b331b1e97 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 13:55:08 +0200 Subject: [PATCH 61/73] simplify canRedact logic in view by overriding in RedactedTile --- src/domain/session/room/timeline/tiles/RedactedTile.js | 4 ++++ src/platform/web/ui/session/room/timeline/BaseMessageView.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index 2363f91c..5b05effa 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -38,6 +38,10 @@ export class RedactedTile extends BaseMessageTile { return this._entry.isRedacting; } + get canRedact() { + return false; + } + abortPendingRedaction() { return this._entry.abortPendingRedaction(); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index c9857638..5557185e 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -94,7 +94,7 @@ export class BaseMessageView extends TemplateView { const options = []; if (vm.isPending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); - } else if (vm.shape !== "redacted" && vm.canRedact) { + } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options; From 5d35caf85f1c7ff509b18b2ae30c0122a9be0eaf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 14:03:22 +0200 Subject: [PATCH 62/73] no need to emit, timeline finds out by themselves with remote echo --- src/matrix/room/sending/SendQueue.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 1c5415e5..e3c6b430 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -124,8 +124,6 @@ export class SendQueue { for (const relatedPE of relatedEventWithoutRemoteId) { relatedPE.setRelatedEventId(remoteId); await this._tryUpdateEventWithTxn(relatedPE, txn); - // emit that we now have a related remote id - // this._pendingEvents.update(relatedPE); } return relatedEventWithoutRemoteId; } From 492a8fe35957db36f45e8abd93b862540444cc6b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 14:05:37 +0200 Subject: [PATCH 63/73] remove extra whiteline --- src/matrix/room/sending/SendQueue.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index e3c6b430..d47b8030 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -195,7 +195,6 @@ export class SendQueue { await this._enqueueEvent(eventType, content, attachments, null, null, log); } - async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) { const pendingEvent = await this._createAndStoreEvent(eventType, content, relatedTxnId, relatedEventId, attachments); this._pendingEvents.set(pendingEvent); From d68d14358fcacb9d86856cb345f89909ee599b94 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 14:08:45 +0200 Subject: [PATCH 64/73] use lower return --- src/matrix/room/timeline/PowerLevels.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index 278c3aa4..87be562f 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -41,8 +41,6 @@ export class PowerLevels { } if (typeof userLevel === "number") { return userLevel; - } else { - return 0; } } else if (this._createEvent) { if (userId === this._createEvent.content?.creator) { From 2bd7c2307614f91759865dd9d99283a7ca06ac71 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 15:08:49 +0200 Subject: [PATCH 65/73] fix lint --- .../storage/idb/stores/RoomStateStore.js | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index e4c0c894..84dd4ec8 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -18,33 +18,33 @@ limitations under the License. import {MAX_UNICODE} from "./common.js"; function encodeKey(roomId, eventType, stateKey) { - return `${roomId}|${eventType}|${stateKey}`; + return `${roomId}|${eventType}|${stateKey}`; } export class RoomStateStore { - constructor(idbStore) { - this._roomStateStore = idbStore; - } + constructor(idbStore) { + this._roomStateStore = idbStore; + } - getAllForType(roomId, type) { - throw new Error("unimplemented"); - } + getAllForType(roomId, type) { + throw new Error("unimplemented"); + } - get(roomId, type, stateKey) { + get(roomId, type, stateKey) { const key = encodeKey(roomId, type, stateKey); - return this._roomStateStore.get(key); - } + return this._roomStateStore.get(key); + } - set(roomId, event) { + set(roomId, event) { const key = encodeKey(roomId, event.type, event.state_key); const entry = {roomId, event, key}; - return this._roomStateStore.put(entry); - } + return this._roomStateStore.put(entry); + } - removeAllForRoom(roomId) { - // 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 = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); - this._roomStateStore.delete(range); - } + removeAllForRoom(roomId) { + // 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 = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); + this._roomStateStore.delete(range); + } } From 00231443d377a120065bbc871b14e1231649c030 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 15:18:44 +0200 Subject: [PATCH 66/73] timeline has the own member, so can just use timeline, not ownUserId --- src/domain/session/SessionViewModel.js | 10 ++-------- src/domain/session/room/RoomViewModel.js | 4 +--- src/domain/session/room/timeline/TimelineViewModel.js | 4 ++-- .../session/room/timeline/tiles/BaseMessageTile.js | 2 +- src/matrix/room/timeline/Timeline.js | 4 ++++ 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 6f2c9797..7a84e67b 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -153,10 +153,7 @@ export class SessionViewModel extends ViewModel { _createRoomViewModel(roomId) { const room = this._sessionContainer.session.rooms.get(roomId); if (room) { - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - })); + const roomVM = new RoomViewModel(this.childOptions({room})); roomVM.load(); return roomVM; } @@ -173,10 +170,7 @@ export class SessionViewModel extends ViewModel { async _createArchivedRoomViewModel(roomId) { const room = await this._sessionContainer.session.loadArchivedRoom(roomId); if (room) { - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - })); + const roomVM = new RoomViewModel(this.childOptions({room})); roomVM.load(); return roomVM; } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 5d08f903..d2d46c32 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -22,9 +22,8 @@ import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { constructor(options) { super(options); - const {room, ownUserId} = options; + const {room} = options; this._room = room; - this._ownUserId = ownUserId; this._timelineVM = null; this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; @@ -46,7 +45,6 @@ export class RoomViewModel extends ViewModel { const timelineVM = this.track(new TimelineViewModel(this.childOptions({ room: this._room, timeline, - ownUserId: this._ownUserId, }))); this._timelineVM = timelineVM; this.emitChange("timelineViewModel"); diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 04ae7570..63791fa3 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -38,9 +38,9 @@ import {ViewModel} from "../../../ViewModel.js"; export class TimelineViewModel extends ViewModel { constructor(options) { super(options); - const {room, timeline, ownUserId} = options; + const {room, timeline} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline, ownUserId}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline}))); } /** diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 8badc429..320aa833 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -20,7 +20,7 @@ import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../ export class BaseMessageTile extends SimpleTile { constructor(options) { super(options); - this._isOwn = this._entry.sender === options.ownUserId; + this._isOwn = this._entry.sender === options.timeline.me.userId; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index ce34bbf8..5a72750f 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -228,4 +228,8 @@ export class Timeline { get powerLevels() { return this._powerLevels; } + + get me() { + return this._ownMember; + } } From 8196a02f9d0e54c490e20e622c4f97e602d83447 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 15:25:01 +0200 Subject: [PATCH 67/73] don't even need isOwn member anymore --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 3 +-- src/domain/session/room/timeline/tiles/SimpleTile.js | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 320aa833..704cccb8 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -20,7 +20,6 @@ import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../ export class BaseMessageTile extends SimpleTile { constructor(options) { super(options); - this._isOwn = this._entry.sender === options.timeline.me.userId; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; } @@ -67,7 +66,7 @@ export class BaseMessageTile extends SimpleTile { } get isOwn() { - return this._isOwn; + return this._entry.sender === this._ownMember.userId; } get isContinuation() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index b6c434df..449bd02a 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -121,10 +121,14 @@ export class SimpleTile extends ViewModel { // TilesCollection contract above get _room() { - return this.getOption("room"); + return this._options.room; } get _powerLevels() { - return this.getOption("timeline").powerLevels; + return this._options.timeline.powerLevels; + } + + get _ownMember() { + return this._options.timeline.me; } } From dc2e21495b104a04dea20fefeca4dd70c824489b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 15:46:57 +0200 Subject: [PATCH 68/73] explain why this is needed --- src/domain/session/room/timeline/tiles/RedactedTile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index 5b05effa..0026859d 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -37,7 +37,8 @@ export class RedactedTile extends BaseMessageTile { get isRedacting() { return this._entry.isRedacting; } - + + /** override parent property to disable redacting, even if still pending */ get canRedact() { return false; } From 25ce06c9d5a4f9d05e7d098909d53ea1dbe62dcb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 15:55:31 +0200 Subject: [PATCH 69/73] clarify --- src/matrix/room/sending/SendQueue.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index d47b8030..7a648171 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -100,8 +100,9 @@ export class SendQueue { if (pendingEvent.needsSending) { await pendingEvent.send(this._hsApi, log); // we now have a remoteId, but this pending event may be removed at any point in the future - // once the remote echo comes in. So if we have any related events that need to resolve - // the relatedTxnId to a related event id, they need to do so now. + // (or past, so can't assume it still exists) once the remote echo comes in. + // So if we have any related events that need to resolve the relatedTxnId to a related event id, + // they need to do so now. // We ensure this by writing the new remote id for the pending event and all related events // with unresolved relatedTxnId in the queue in one transaction. const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); From 13a4a0169cae1f90dc287733d805e32588e9cf8e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 16:23:59 +0200 Subject: [PATCH 70/73] remove obsolete comments --- src/matrix/room/timeline/Timeline.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 5a72750f..9251c833 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -103,7 +103,6 @@ export class Timeline { }, pee => { this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.removeLocalRelation(pee)); }); - // we need a hook for when a pee is removed, so we can remove the local relation } else { this._localEntries = new ObservableArray(); } @@ -122,8 +121,6 @@ export class Timeline { ); // now look in remote entries based on event id if (!foundInLocalEntries && pe.relatedEventId) { - // TODO: ideally iterate in reverse as target is likely to be most recent, - // but not easy through ObservableList contract this._remoteEntries.findAndUpdate( e => e.id === pe.relatedEventId, updateOrFalse @@ -137,8 +134,6 @@ export class Timeline { replaceEntries(entries) { for (const entry of entries) { - // this will use the comparator and thus - // check for equality using the compare method in BaseEntry this._remoteEntries.update(entry, null, previousEntry => { entry.transferLocalEchoState(previousEntry); }); From addddf1f26982efbdaf0ebff13f3033f084555de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 31 May 2021 16:24:42 +0200 Subject: [PATCH 71/73] remove need for transferLocalEchoState, just add local relations again --- src/matrix/room/timeline/Timeline.js | 27 ++++++++++--------- .../room/timeline/entries/BaseEventEntry.js | 7 ----- .../timeline/entries/FragmentBoundaryEntry.js | 1 - src/observable/list/SortedArray.js | 6 +---- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 9251c833..cced535c 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -133,24 +133,27 @@ export class Timeline { } replaceEntries(entries) { + this._addLocalRelationsToNewRemoteEntries(entries); for (const entry of entries) { - this._remoteEntries.update(entry, null, previousEntry => { - entry.transferLocalEchoState(previousEntry); - }); + this._remoteEntries.update(entry); + } + } + + _addLocalRelationsToNewRemoteEntries(entries) { + // find any local relations to this new remote event + 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); + } } } /** @package */ addOrReplaceEntries(newEntries) { - // find any local relations to this new remote event - for (const pee of this._localEntries) { - // this will work because we set relatedEventId when removing remote echos - if (pee.relatedEventId) { - const relationTarget = newEntries.find(e => e.id === pee.relatedEventId); - // no need to emit here as this entry is about to be added - relationTarget?.addLocalRelation(pee); - } - } + this._addLocalRelationsToNewRemoteEntries(newEntries); this._remoteEntries.setManySorted(newEntries); } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index a3748c8b..e474deb4 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -31,13 +31,6 @@ export class BaseEventEntry extends BaseEntry { return this.isRedacting; } - // when replacing an entry, local echo state can be transfered here - transferLocalEchoState(oldEntry) { - if (oldEntry._pendingRedactions) { - this._pendingRedactions = oldEntry._pendingRedactions; - } - } - /** aggregates local relation. @return [string] returns the name of the field that has changed, if any diff --git a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js index 081d18a2..0eb2a5b6 100644 --- a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js +++ b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js @@ -134,7 +134,6 @@ export class FragmentBoundaryEntry extends BaseEntry { return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer); } - transferLocalEchoState() {} addLocalRelation() {} removeLocalRelation() {} } diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index cf17449b..39cfcde5 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -46,13 +46,9 @@ export class SortedArray extends BaseObservableList { return findAndUpdateInArray(predicate, this._items, this, updater); } - update(item, updateParams = null, previousCallback = null) { + update(item, updateParams = null) { const idx = this.indexOf(item); if (idx !== -1) { - if (previousCallback) { - const oldItem = this._items[idx]; - previousCallback(oldItem); - } this._items[idx] = item; this.emitUpdate(idx, item, updateParams); } From 15f6ab8b7ee154ecfaa36685f0157215f544f7ff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 2 Jun 2021 11:56:15 +0200 Subject: [PATCH 72/73] only show cancel option if not already sending --- src/domain/session/room/timeline/tiles/SimpleTile.js | 8 +++++++- .../web/ui/session/room/timeline/BaseMessageView.js | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 449bd02a..f4584bcf 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -49,7 +49,13 @@ export class SimpleTile extends ViewModel { } get isUnsent() { - return this._entry.isPending && this._entry.status !== SendStatus.Sent; + return this._entry.isPending && this._entry.pendingEvent.status !== SendStatus.Sent; + } + + get canAbortSending() { + return this._entry.isPending && + this._entry.pendingEvent.status !== SendStatus.Sending && + this._entry.pendingEvent.status !== SendStatus.Sent; } abortSending() { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 5557185e..60b39048 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -92,7 +92,7 @@ export class BaseMessageView extends TemplateView { createMenuOptions(vm) { const options = []; - if (vm.isPending) { + 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()); From 7a96f84caba4145c7cce6e380a0404ba166184ee Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 2 Jun 2021 12:17:09 +0200 Subject: [PATCH 73/73] also show redaction reason for redaction local echo --- src/domain/session/room/timeline/tiles/RedactedTile.js | 6 +++++- src/matrix/room/timeline/entries/BaseEventEntry.js | 7 +++++++ src/matrix/room/timeline/entries/EventEntry.js | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js index 0026859d..c0da3c92 100644 --- a/src/domain/session/room/timeline/tiles/RedactedTile.js +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -24,7 +24,11 @@ export class RedactedTile extends BaseMessageTile { get description() { const {redactionReason} = this._entry; if (this.isRedacting) { - return this.i18n`This message is being deleted …`; + if (redactionReason) { + return this.i18n`This message is being deleted (${redactionReason})…`; + } else { + return this.i18n`This message is being deleted…`; + } } else { if (redactionReason) { return this.i18n`This message has been deleted (${redactionReason}).`; diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index e474deb4..4bf09ed5 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -31,6 +31,13 @@ export class BaseEventEntry extends BaseEntry { return this.isRedacting; } + get redactionReason() { + if (this._pendingRedactions) { + return this._pendingRedactions[0].content?.reason; + } + return null; + } + /** aggregates local relation. @return [string] returns the name of the field that has changed, if any diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index d3287799..4dbb352f 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -122,6 +122,6 @@ export class EventEntry extends BaseEventEntry { if (redactionEvent) { return redactionEvent.content?.reason; } - return null; + return super.redactionReason; } } \ No newline at end of file