commit
8dfed73524
57 changed files with 1530 additions and 492 deletions
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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})));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
53
src/domain/session/room/timeline/tiles/RedactedTile.js
Normal file
53
src/domain/session/room/timeline/tiles/RedactedTile.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -20,3 +20,18 @@ export function makeTxnId() {
|
|||
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"));
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
95
src/matrix/room/timeline/PowerLevels.js
Normal file
95
src/matrix/room/timeline/PowerLevels.js
Normal file
|
@ -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);
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
83
src/matrix/room/timeline/entries/BaseEventEntry.js
Normal file
83
src/matrix/room/timeline/entries/BaseEventEntry.js
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -133,4 +133,7 @@ export class FragmentBoundaryEntry extends BaseEntry {
|
|||
createNeighbourEntry(neighbour) {
|
||||
return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer);
|
||||
}
|
||||
|
||||
addLocalRelation() {}
|
||||
removeLocalRelation() {}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
101
src/matrix/room/timeline/persistence/RelationWriter.js
Normal file
101
src/matrix/room/timeline/persistence/RelationWriter.js
Normal file
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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);
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
32
src/observable/list/common.js
Normal file
32
src/observable/list/common.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 {
|
||||
|
|
233
src/platform/web/ui/css/themes/element/timeline.css
Normal file
233
src/platform/web/ui/css/themes/element/timeline.css
Normal file
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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;
|
||||
}
|
|
@ -43,11 +43,6 @@ limitations under the License.
|
|||
display: block;
|
||||
}
|
||||
|
||||
.TextMessageView {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.AnnouncementView {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
]);
|
||||
|
|
104
src/platform/web/ui/session/room/timeline/BaseMessageView.js
Normal file
104
src/platform/web/ui/session/room/timeline/BaseMessageView.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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() {}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
32
src/platform/web/ui/session/room/timeline/RedactedView.js
Normal file
32
src/platform/web/ui/session/room/timeline/RedactedView.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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)
|
||||
);
|
||||
}
|
82
yarn.lock
82
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"
|
||||
|
|
Reference in a new issue