Merge pull request #361 from vector-im/bwindels/redactions

Redactions
This commit is contained in:
Bruno Windels 2021-06-02 10:22:33 +00:00 committed by GitHub
commit 8dfed73524
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1530 additions and 492 deletions

View file

@ -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",

View file

@ -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

View file

@ -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;
}

View file

@ -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");

View file

@ -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"]);
}
}
}

View file

@ -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})));
}
/**

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View 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();
}
}

View file

@ -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) {
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(params);
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;
}
}

View file

@ -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) {

View file

@ -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"));
},
}
}

View file

@ -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);

View file

@ -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 */

View file

@ -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) {

View file

@ -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";

View file

@ -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,6 +169,15 @@ export class PendingEvent {
this._emitUpdate("status");
const eventType = this._data.encryptedEventType || this._data.eventType;
const content = this._data.encryptedContent || this._data.content;
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,
@ -163,8 +185,10 @@ export class PendingEvent {
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;

View file

@ -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,11 +60,22 @@ export class SendQueue {
if (err instanceof ConnectionError) {
this._offline = true;
log.set("offline", true);
pendingEvent.setWaiting();
} else {
log.catch(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);
}
}
}
});
}
} finally {
@ -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);

View 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);
},
}
}

View file

@ -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;
}
}

View 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
}
}
}

View file

@ -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;
}
}

View file

@ -133,4 +133,7 @@ export class FragmentBoundaryEntry extends BaseEntry {
createNeighbourEntry(neighbour) {
return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer);
}
addLocalRelation() {}
removeLocalRelation() {}
}

View file

@ -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;
}
}

View file

@ -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};
}
}

View 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

View file

@ -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) {

View file

@ -17,21 +17,26 @@ limitations under the License.
import {MAX_UNICODE} from "./common.js";
function encodeKey(roomId, eventType, stateKey) {
return `${roomId}|${eventType}|${stateKey}`;
}
export class RoomStateStore {
constructor(idbStore) {
this._roomStateStore = idbStore;
}
async getAllForType(type) {
getAllForType(roomId, type) {
throw new Error("unimplemented");
}
async get(type, stateKey) {
throw new Error("unimplemented");
get(roomId, type, stateKey) {
const key = encodeKey(roomId, type, stateKey);
return this._roomStateStore.get(key);
}
async set(roomId, event) {
const key = `${roomId}|${event.type}|${event.state_key}`;
set(roomId, event) {
const key = encodeKey(roomId, event.type, event.state_key);
const entry = {roomId, event, key};
return this._roomStateStore.put(entry);
}

View file

@ -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);
}

View file

@ -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);
},
};
}

View file

@ -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);

View file

@ -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);
}
}
}

View file

@ -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

View 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;
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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?

View file

@ -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 {

View 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;
}

View file

@ -43,11 +43,6 @@ limitations under the License.
display: block;
}
.TextMessageView {
display: flex;
min-width: 0;
}
.AnnouncementView {
display: flex;
align-items: center;

View file

@ -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);
try {
const childDomNode = child.mount(this._mountArgs);
fragment.appendChild(childDomNode);
} catch (err) {
fragment.appendChild(errorToDOM(err));
}
}
this._root.appendChild(fragment);
}

View file

@ -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;
}
}

View file

@ -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,10 +104,11 @@ 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);
}
}
_onClick() {
this.close();
@ -186,7 +191,15 @@ function findScrollParent(el) {
do {
parent = parent.parentElement;
if (parent.scrollHeight > parent.clientHeight) {
// 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);
}

View file

@ -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);
}
}

View file

@ -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),

View file

@ -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()));

View file

@ -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);

View file

@ -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() {}
}

View file

@ -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))
]);

View 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() {}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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;
}
}

View file

@ -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)
]);
}
}

View file

@ -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]);
}
}

View file

@ -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)
);
}

View file

@ -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"