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/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 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/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 2a53c9d4..0acb2859 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) { @@ -191,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 @@ -297,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/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index a5dcee07..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, 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 a348a249..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.ownUserId; 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() { @@ -87,8 +86,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); } @@ -97,4 +96,12 @@ export class BaseMessageTile extends SimpleTile { this.emitChange("isContinuation"); } } + + 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/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index c2cf2f56..1e227876 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) { @@ -76,3 +72,30 @@ 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) { + 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/RedactedTile.js b/src/domain/session/room/timeline/tiles/RedactedTile.js new file mode 100644 index 00000000..c0da3c92 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/RedactedTile.js @@ -0,0 +1,53 @@ +/* +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 {BaseMessageTile} from "./BaseMessageTile.js"; + +export class RedactedTile extends BaseMessageTile { + get shape() { + return "redacted"; + } + + get description() { + const {redactionReason} = this._entry; + if (this.isRedacting) { + 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}).`; + } else { + return this.i18n`This message has been deleted.`; + } + } + } + + get isRedacting() { + return this._entry.isRedacting; + } + + /** override parent property to disable redacting, even if still pending */ + get canRedact() { + return false; + } + + abortPendingRedaction() { + return this._entry.abortPendingRedaction(); + } +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index ea5640bd..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() { @@ -83,9 +89,15 @@ 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); + updateEntry(entry, param) { + const renderedAsRedacted = this.shape === "redacted"; + if (!entry.isGap && entry.isRedacted !== renderedAsRedacted) { + // recreate the tile if the entry becomes redacted + return UpdateAction.Replace("shape"); + } else { + this._entry = entry; + return UpdateAction.Update(param); + } } // return whether the tile should be removed @@ -113,4 +125,16 @@ export class SimpleTile extends ViewModel { super.dispose(); } // TilesCollection contract above + + get _room() { + return this._options.room; + } + + get _powerLevels() { + return this._options.timeline.powerLevels; + } + + get _ownMember() { + return this._options.timeline.me; + } } 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/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/BaseRoom.js b/src/matrix/room/BaseRoom.js index 924e5316..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); @@ -263,7 +264,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, @@ -288,6 +289,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); } }); @@ -298,7 +301,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 57382831..da9eef52 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); @@ -158,13 +159,13 @@ 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, roomEncryption, newEntries, - updatedEntries: retryEntries || [], + updatedEntries, newLiveKey, removedPendingEvents, memberChanges, @@ -279,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; } @@ -296,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/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 e6d518ca..ef5d086e 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,6 +15,7 @@ limitations under the License. */ import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; +import {REDACTION_TYPE} from "../common.js"; export const SendStatus = createEnum( "Waiting", @@ -47,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) { @@ -86,6 +94,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; } @@ -134,7 +147,7 @@ export class PendingEvent { this._data.needsUpload = false; } - abort() { + async abort() { if (!this._aborted) { this._aborted = true; if (this._attachments) { @@ -143,7 +156,7 @@ export class PendingEvent { } } this._sendRequest?.abort(); - this._removeFromQueueCallback(); + await this._removeFromQueueCallback(); } } @@ -156,15 +169,26 @@ 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) { + this._sendRequest = hsApi.redact( + this.roomId, + this._data.relatedEventId, + 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; diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 4ad5e527..7a648171 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -16,8 +16,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 {PendingEvent, SendStatus} from "./PendingEvent.js"; +import {makeTxnId, isTxnId} from "../../common.js"; +import {REDACTION_TYPE} from "../common.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -46,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 { @@ -73,9 +60,20 @@ export class SendQueue { if (err instanceof ConnectionError) { this._offline = true; log.set("offline", true); + 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); + } } } }); @@ -101,12 +99,37 @@ 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 + // (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]); + try { + await this._tryUpdateEventWithTxn(pendingEvent, txn); + await this._resolveRemoteIdInPendingRelations( + pendingEvent.txnId, pendingEvent.remoteId, txn); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); } } - 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); + } + return relatedEventWithoutRemoteId; + } + + async removeRemoteEchos(events, txn, parentLog) { const removed = []; for (const event of events) { const txnId = event.unsigned && event.unsigned.transaction_id; @@ -118,9 +141,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; @@ -168,7 +193,11 @@ export class SendQueue { } async enqueueEvent(eventType, content, attachments, log) { - const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments); + 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); log.set("queueIndex", pendingEvent.queueIndex); log.set("pendingEvents", this._pendingEvents.length); @@ -180,6 +209,43 @@ export class SendQueue { } } + async enqueueRedaction(eventIdOrTxnId, reason, log) { + let relatedTxnId; + let relatedEventId; + if (isTxnId(eventIdOrTxnId)) { + 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) { + 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 + // we don't do anything as this race is quite unlikely + // and a bit complicated to fix. + return; + } + } else { + relatedEventId = eventIdOrTxnId; + 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); + } + get pendingEvents() { return this._pendingEvents; } @@ -187,11 +253,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 +261,31 @@ 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, relatedTxnId, relatedEventId, 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, + relatedTxnId, + relatedEventId, txnId: makeTxnId(), - needsEncryption: !!this._roomEncryption, + needsEncryption, needsUpload: !!attachments }, attachments); pendingEventsStore.add(pendingEvent.data); diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js new file mode 100644 index 00000000..87be562f --- /dev/null +++ b/src/matrix/room/timeline/PowerLevels.js @@ -0,0 +1,95 @@ +/* +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 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 74040586..cced535c 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. @@ -20,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}) { @@ -28,7 +30,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,22 +40,16 @@ 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; + 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); @@ -69,10 +67,65 @@ 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); } + 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) { + 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)); + }); + } else { + this._localEntries = new ObservableArray(); + } + this._allEntries = new ConcatList(this._remoteEntries, this._localEntries); + } + + _applyAndEmitLocalRelationChange(pe, updater) { + 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, + updateOrFalse, + ); + // now look in remote entries based on event id + if (!foundInLocalEntries && pe.relatedEventId) { + this._remoteEntries.findAndUpdate( + e => e.id === pe.relatedEventId, + updateOrFalse + ); + } } updateOwnMember(member) { @@ -80,15 +133,27 @@ export class Timeline { } replaceEntries(entries) { + this._addLocalRelationsToNewRemoteEntries(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); } } + _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) { + this._addLocalRelationsToNewRemoteEntries(newEntries); this._remoteEntries.setManySorted(newEntries); } @@ -114,7 +179,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); @@ -128,6 +193,7 @@ export class Timeline { return entry; } } + return null; } /** @public */ @@ -152,7 +218,16 @@ export class Timeline { } } + /** @internal */ enableEncryption(decryptEntries) { this._timelineReader.enableEncryption(decryptEntries); } + + get powerLevels() { + return this._powerLevels; + } + + get me() { + return this._ownMember; + } } diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js new file mode 100644 index 00000000..4bf09ed5 --- /dev/null +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -0,0 +1,83 @@ +/* +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._pendingRedactions = null; + } + + get isRedacting() { + return !!this._pendingRedactions; + } + + get isRedacted() { + 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 + */ + addLocalRelation(entry) { + if (entry.eventType === REDACTION_TYPE) { + if (!this._pendingRedactions) { + this._pendingRedactions = []; + } + this._pendingRedactions.push(entry); + if (this._pendingRedactions.length === 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._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/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 88a0aa5e..4dbb352f 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; @@ -108,4 +108,20 @@ export class EventEntry extends BaseEntry { get decryptionError() { return this._decryptionError; } -} + + get relatedEventId() { + return this._eventEntry.event.redacts; + } + + get isRedacted() { + return super.isRedacted || !!this._eventEntry.event.unsigned?.redacted_because; + } + + get redactionReason() { + const redactionEvent = this._eventEntry.event.unsigned?.redacted_because; + if (redactionEvent) { + return redactionEvent.content?.reason; + } + return super.redactionReason; + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js index f43f273b..0eb2a5b6 100644 --- a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js +++ b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js @@ -133,4 +133,7 @@ export class FragmentBoundaryEntry extends BaseEntry { createNeighbourEntry(neighbour) { return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer); } + + addLocalRelation() {} + removeLocalRelation() {} } 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; + } } diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index ebd2bedf..67668298 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) { @@ -103,8 +105,9 @@ export class GapWriter { } } - _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, // e.g. order is moving away from the `from` point. let key = startKey; @@ -120,8 +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, txn, log); + if (updatedRelationTargetEntry) { + updatedEntries.push(updatedRelationTargetEntry); + } } - return entries; + return {entries, updatedEntries}; } _findMember(userId, state, events, index, direction) { @@ -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"); @@ -225,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 @@ -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} = await this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn, log); 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..45716d04 --- /dev/null +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -0,0 +1,101 @@ +/* +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, log) { + if (sourceEntry.relatedEventId) { + const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); + if (target) { + if (this._applyRelation(sourceEntry, target, log)) { + txn.timelineEvents.update(target); + return new EventEntry(target, this._fragmentIdComparer); + } + } + } + return; + } + + _applyRelation(sourceEntry, targetEntry, log) { + if (sourceEntry.eventType === REDACTION_TYPE) { + return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log)); + } else { + return false; + } + } + + _applyRedaction(redactionEvent, targetEvent, log) { + log.set("redactionId", redactionEvent.event_id); + log.set("id", targetEvent.event_id); + // TODO: should we make efforts to preserve the decrypted event type? + // probably ok not to, as we'll show whatever is deleted as "deleted message" + // reactions are the only thing that comes to mind, but we don't encrypt those (for now) + for (const key of Object.keys(targetEvent)) { + if (!_REDACT_KEEP_KEY_MAP[key]) { + delete targetEvent[key]; + } + } + const {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..671b944a 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, txn, log); + 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(timeline, this._lastLiveKey, memberChanges, txn, log); log.set("memberChanges", memberChanges.size); - return {entries, newLiveKey: currentKey, memberChanges}; + return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; } afterSync(newLiveKey) { diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 20ef6942..84dd4ec8 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -17,29 +17,34 @@ limitations under the License. import {MAX_UNICODE} from "./common.js"; -export class RoomStateStore { - constructor(idbStore) { - this._roomStateStore = idbStore; - } - - async getAllForType(type) { - throw new Error("unimplemented"); - } - - async get(type, stateKey) { - throw new Error("unimplemented"); - } - - async set(roomId, event) { - const key = `${roomId}|${event.type}|${event.state_key}`; - const entry = {roomId, event, key}; - 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); - } +function encodeKey(roomId, eventType, stateKey) { + return `${roomId}|${eventType}|${stateKey}`; +} + +export class RoomStateStore { + constructor(idbStore) { + this._roomStateStore = idbStore; + } + + getAllForType(roomId, type) { + throw new Error("unimplemented"); + } + + get(roomId, type, stateKey) { + const key = encodeKey(roomId, type, stateKey); + return this._roomStateStore.get(key); + } + + set(roomId, event) { + const key = encodeKey(roomId, event.type, event.state_key); + const entry = {roomId, event, key}; + 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); + } } 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 139a6e73..ddb61384 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. @@ -15,13 +16,15 @@ limitations under the License. */ import {BaseObservableList} from "./BaseObservableList.js"; +import {findAndUpdateInArray} from "./common.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; } @@ -46,6 +49,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); @@ -56,6 +63,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 +80,10 @@ export class MappedList extends BaseObservableList { this._sourceUnsubscribe(); } + findAndUpdate(predicate, updater) { + return findAndUpdateInArray(predicate, this._mappedValues, this, updater); + } + get length() { return this._mappedValues.length; } @@ -79,6 +93,8 @@ export class MappedList extends BaseObservableList { } } +import {ObservableArray} from "./ObservableArray.js"; + export async function tests() { class MockList extends BaseObservableList { get length() { @@ -126,6 +142,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); + }, + }; } 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); diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 193661cf..39cfcde5 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,6 +42,10 @@ export class SortedArray extends BaseObservableList { } } + findAndUpdate(predicate, updater) { + return findAndUpdateInArray(predicate, this._items, this, updater); + } + update(item, updateParams = null) { const idx = this.indexOf(item); if (idx !== -1) { @@ -58,6 +63,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 +101,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 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/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 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? diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f73f5edd..b0bf7854 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; @@ -37,6 +38,10 @@ limitations under the License. --usercolor8: #74D12C; } +.hydrogen button { + font-family: inherit; +} + .avatar { border-radius: 100%; background: #fff; @@ -507,168 +512,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; } @@ -827,14 +670,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/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css new file mode 100644 index 00000000..d7eac940 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -0,0 +1,233 @@ +/* +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. +*/ + +.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, .Timeline_message.menuOpen { + background-color: rgba(141, 151, 165, 0.1); + border-radius: 4px; +} + +.Timeline_message:hover > .Timeline_messageOptions, +.Timeline_message.menuOpen > .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: end; + margin-top: -12px; + margin-right: 4px; + /* button visuals */ + border: #ccc 1px solid; + height: 24px; + width: 24px; + background-color: #fff; + border-radius: 4px; + padding: 0; + text-align: center; + line-height: 22px; + cursor: pointer; +} + +.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.statusMessage { + font-style: italic; + color: #777; +} + +.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_message.unsent .Timeline_messageBody { + color: #ccc; +} + +.Timeline_message.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; +} + +.Timeline_messageBody a { + word-break: break-all; +} \ No newline at end of file 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/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/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/general/Popup.js b/src/platform/web/ui/general/Popup.js index b927b44b..ac5e3160 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(); + } } } @@ -100,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() { @@ -186,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); } 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/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), diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index f89e2fff..40d7d3c4 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -69,10 +69,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())); diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 791e23a3..a0ad1c83 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) { @@ -34,6 +35,8 @@ function viewClassForEntry(entry) { case "video": return VideoView; case "file": return FileView; case "missing-attachment": return MissingAttachmentView; + case "redacted": + return RedactedView; } } @@ -42,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/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index b5b0ed4b..c52fbaed 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) { @@ -37,13 +36,12 @@ export class BaseMediaView extends TemplateView { 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, @@ -52,7 +50,7 @@ export class BaseMediaView extends TemplateView { }); children.push(sendStatus, progress); } - return renderMessage(t, vm, [ + 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..60b39048 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -0,0 +1,104 @@ +/* +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 {tag} from "../../../general/html.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 li = t.li({className: { + "Timeline_message": true, + own: vm.isOwn, + unsent: vm.isUnsent, + unverified: vm.isUnverified, + continuation: vm => vm.isContinuation, + }}, [ + 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.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); + li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); + } + }); + return li; + } + + /* 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"); + const onClose = () => this.root().classList.remove("menuOpen"); + this._menuPopup = new Popup(new Menu(options), onClose); + 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.canAbortSending) { + options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); + } else if (vm.canRedact) { + options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); + } + return options; + } + + renderMessageBody() {} +} diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index 62760b3e..6a2d418e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -14,22 +14,19 @@ 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) { + const children = []; if (vm.isPending) { - return renderMessage(t, vm, t.p([ - vm => vm.label, - " ", - t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), - ])); + children.push(vm => vm.label); } else { - return renderMessage(t, vm, t.p([ + 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 8df90131..473a1acc 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -14,12 +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 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])); +export class MissingAttachmentView extends BaseMessageView { + renderMessageBody(t, vm) { + return t.p({className: "Timeline_messageBody statusMessage"}, vm.label); } } 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..ec30c1e7 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/RedactedView.js @@ -0,0 +1,32 @@ +/* +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 {BaseMessageView} from "./BaseMessageView.js"; +import {Menu} from "../../../general/Menu.js"; + +export class RedactedView extends BaseMessageView { + renderMessageBody(t) { + 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 diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index dc5550ee..d0aae8f3 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -14,17 +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([bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] - ); +export class TextMessageView extends BaseMessageView { + renderMessageBody(t, vm) { + return t.p({ + className: "Timeline_messageBody", + statusMessage: vm => vm.shape === "message-status" + }, [ + 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/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]); - } -} 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 22bcd6b1..00000000 --- a/src/platform/web/ui/session/room/timeline/common.js +++ /dev/null @@ -1,39 +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, children) { - const classes = { - "TextMessageView": 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", - }; - - const profile = t.div({className: "profile"}, [ - renderStaticAvatar(vm, 30), - t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.displayName) - ]); - children = [profile].concat(children); - return t.li( - {className: classes}, - t.div({className: "message-container"}, children) - ); -} diff --git a/yarn.lock b/yarn.lock index 7cf727a4..d29a6d84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1014,16 +1014,16 @@ 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: - version "10.0.1" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.0.1.tgz#e2d9000f84ebd98d77b7bc16f8adb2ff1f7bb946" - integrity sha512-aQo2BDIsoOdemXUAOBpFv4ZQa2DrOtEufarYhtFsK1088Ca0TUwu/aQWf0M3mrILXZ3mTIVn1lR3hPW8acacsw== +autoprefixer@^10.2.6: + 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"