Merge branch 'master' into room-info

This commit is contained in:
Bruno Windels 2021-06-24 15:06:37 +00:00 committed by GitHub
commit 09aba78803
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2413 additions and 336 deletions

View file

@ -9,13 +9,12 @@ SyncWriter will need to resolve the related remote id to a [fragmentId, eventInd
sourceEventId:
targetEventId:
rel_type:
type:
roomId:
}
`{"key": "!bEWtlqtDwCLFIAKAcv:matrix.org|$apmyieZOI5vm4DzjEFzjbRiZW9oeQQR21adM6A6eRwM|m.annotation|m.reaction|$jSisozR3is5XUuDZXD5cyaVMOQ5_BtFS3jKfcP89MOM"}`
or actually stored like `roomId|targetEventId|rel_type|source_event_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event
or actually stored like `roomId|targetEventId|rel_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event
We should look into what part of the relationships will be present on the event once it is received from the server (e.g. m.replace might be evident, but not all the reaction events?). If not, we could add a object store with missing relation targets.

View file

@ -0,0 +1,363 @@
/*
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 {ObservableMap} from "../../../../observable/map/ObservableMap.js";
export class ReactionsViewModel {
constructor(parentTile) {
this._parentTile = parentTile;
this._map = new ObservableMap();
this._reactions = this._map.sortValues((a, b) => a._compare(b));
}
/** @package */
update(annotations, pendingAnnotations) {
if (annotations) {
for (const key in annotations) {
if (annotations.hasOwnProperty(key)) {
const annotation = annotations[key];
const reaction = this._map.get(key);
if (reaction) {
if (reaction._tryUpdate(annotation)) {
this._map.update(key);
}
} else {
this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentTile));
}
}
}
}
if (pendingAnnotations) {
for (const [key, annotation] of pendingAnnotations.entries()) {
const reaction = this._map.get(key);
if (reaction) {
reaction._tryUpdatePending(annotation);
this._map.update(key);
} else {
this._map.add(key, new ReactionViewModel(key, null, annotation, this._parentTile));
}
}
}
for (const existingKey of this._map.keys()) {
const hasPending = pendingAnnotations?.has(existingKey);
const hasRemote = annotations?.hasOwnProperty(existingKey);
if (!hasRemote && !hasPending) {
this._map.remove(existingKey);
} else if (!hasRemote) {
if (this._map.get(existingKey)._tryUpdate(null)) {
this._map.update(existingKey);
}
} else if (!hasPending) {
if (this._map.get(existingKey)._tryUpdatePending(null)) {
this._map.update(existingKey);
}
}
}
}
get reactions() {
return this._reactions;
}
getReaction(key) {
return this._map.get(key);
}
}
class ReactionViewModel {
constructor(key, annotation, pending, parentTile) {
this._key = key;
this._annotation = annotation;
this._pending = pending;
this._parentTile = parentTile;
this._isToggling = false;
}
_tryUpdate(annotation) {
const oneSetAndOtherNot = !!this._annotation !== !!annotation;
const bothSet = this._annotation && annotation;
const areDifferent = bothSet && (
annotation.me !== this._annotation.me ||
annotation.count !== this._annotation.count ||
annotation.firstTimestamp !== this._annotation.firstTimestamp
);
if (oneSetAndOtherNot || areDifferent) {
this._annotation = annotation;
return true;
}
return false;
}
_tryUpdatePending(pending) {
if (!pending && !this._pending) {
return false;
}
this._pending = pending;
return true;
}
get key() {
return this._key;
}
get count() {
return (this._pending?.count || 0) + (this._annotation?.count || 0);
}
get isPending() {
return this._pending !== null;
}
/** @returns {boolean} true if the user has a (pending) reaction
* already for this key, or they have a pending redaction for
* the reaction, false if there is nothing pending and
* the user has not reacted yet. */
get isActive() {
return this._annotation?.me || this.isPending;
}
get firstTimestamp() {
let ts = Number.MAX_SAFE_INTEGER;
if (this._annotation) {
ts = Math.min(ts, this._annotation.firstTimestamp);
}
if (this._pending) {
ts = Math.min(ts, this._pending.firstTimestamp);
}
return ts;
}
_compare(other) {
// the comparator is also used to test for equality by sortValues, if the comparison returns 0
// given that the firstTimestamp isn't set anymore when the last reaction is removed,
// the remove event wouldn't be able to find the correct index anymore. So special case equality.
if (other === this) {
return 0;
}
if (this.count !== other.count) {
return other.count - this.count;
} else {
const cmp = this.firstTimestamp - other.firstTimestamp;
if (cmp === 0) {
return this.key < other.key ? -1 : 1;
}
return cmp;
}
}
async toggle(log = null) {
if (this._isToggling) {
console.log("busy toggling reaction already");
return;
}
this._isToggling = true;
try {
await this._parentTile.toggleReaction(this.key, log);
} finally {
this._isToggling = false;
}
}
}
// matrix classes uses in the integration test below
import {User} from "../../../../matrix/User.js";
import {SendQueue} from "../../../../matrix/room/sending/SendQueue.js";
import {Timeline} from "../../../../matrix/room/timeline/Timeline.js";
import {EventEntry} from "../../../../matrix/room/timeline/entries/EventEntry.js";
import {RelationWriter} from "../../../../matrix/room/timeline/persistence/RelationWriter.js";
import {FragmentIdComparer} from "../../../../matrix/room/timeline/FragmentIdComparer.js";
import {createAnnotation} from "../../../../matrix/room/timeline/relations.js";
// mocks
import {Clock as MockClock} from "../../../../mocks/Clock.js";
import {createMockStorage} from "../../../../mocks/Storage.js";
import {ListObserver} from "../../../../mocks/ListObserver.js";
import {createEvent, withTextBody, withContent} from "../../../../mocks/event.js";
import {NullLogItem, NullLogger} from "../../../../logging/NullLogger.js";
import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
// other imports
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
import {MappedList} from "../../../../observable/list/MappedList.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
const logger = new NullLogger();
function findInIterarable(it, predicate) {
let i = 0;
for (const item of it) {
if (predicate(item, i)) {
return item;
}
i += 1;
}
throw new Error("not found");
}
function mapMessageEntriesToBaseMessageTile(timeline, queue) {
const room = {
id: roomId,
sendEvent(eventType, content, attachments, log) {
return queue.enqueueEvent(eventType, content, attachments, log);
},
sendRedaction(eventIdOrTxnId, reason, log) {
return queue.enqueueRedaction(eventIdOrTxnId, reason, log);
}
};
const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") {
return new BaseMessageTile({entry, room, timeline, platform: {logger}});
}
return null;
}, (tile, params, entry) => tile?.updateEntry(entry, params));
return tiles;
}
return {
// these are more an integration test than unit tests,
// but fully test the local echo when toggling and
// the correct send queue modifications happen
"toggling reaction with own remote reaction": async assert => {
// 1. put message and reaction in storage
const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const myReactionEvent = withContent(createAnnotation(messageEvent.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
myReactionEvent.origin_server_ts = 5;
const myReactionEntry = new EventEntry({event: myReactionEvent, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([
storage.storeNames.timelineEvents,
storage.storeNames.timelineRelations,
storage.storeNames.timelineFragments
]);
txn.timelineFragments.add({id: 1, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReactionEvent, roomId});
await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem());
await txn.complete();
// 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api});
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
clock: new MockClock(), pendingEvents: queue.pendingEvents});
// 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem());
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
// 4. subscribe to the queue to observe, and the tiles (so we can safely iterate)
const queueObserver = new ListObserver();
queue.pendingEvents.subscribe(queueObserver);
tiles.subscribe(new ListObserver());
const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null
const reactionVM = messageTile.reactions.getReaction("🐶");
// 5. test toggling
// make sure the preexisting reaction is counted
assert.equal(reactionVM.count, 1);
// 5.1. unset reaction, should redact the pre-existing reaction
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {value: redaction, type} = await queueObserver.next();
assert.equal("add", type);
assert.equal(redaction.eventType, "m.room.redaction");
assert.equal(redaction.relatedEventId, myReactionEntry.id);
// SendQueue puts redaction in sending status, as it is first in the queue
assert.equal("update", (await queueObserver.next()).type);
}
// 5.2. set reaction, should send a new reaction as the redaction is already sending
await reactionVM.toggle();
let reactionIndex;
{
assert.equal(reactionVM.count, 1);
const {value: reaction, type, index} = await queueObserver.next();
assert.equal("add", type);
assert.equal(reaction.eventType, "m.reaction");
assert.equal(reaction.relatedEventId, messageEvent.event_id);
reactionIndex = index;
}
// 5.3. unset reaction, should abort the previous pending reaction as it hasn't started sending yet
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {index, type} = await queueObserver.next();
assert.equal("remove", type);
assert.equal(reactionIndex, index);
}
},
"toggling reaction without own remote reaction": async assert => {
// 1. put message in storage
const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([
storage.storeNames.timelineEvents,
storage.storeNames.timelineFragments
]);
txn.timelineFragments.add({id: 1, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId});
await txn.complete();
// 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api});
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
clock: new MockClock(), pendingEvents: queue.pendingEvents});
// 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem());
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
// 4. subscribe to the queue to observe, and the tiles (so we can safely iterate)
const queueObserver = new ListObserver();
queue.pendingEvents.subscribe(queueObserver);
tiles.subscribe(new ListObserver());
const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null
// 5. test toggling
assert.equal(messageTile.reactions, null);
// 5.1. set reaction, should send a new reaction as there is none yet
await messageTile.react("🐶");
// now there should be a reactions view model
const reactionVM = messageTile.reactions.getReaction("🐶");
let reactionTxnId;
{
assert.equal(reactionVM.count, 1);
const {value: reaction, type} = await queueObserver.next();
assert.equal("add", type);
assert.equal(reaction.eventType, "m.reaction");
assert.equal(reaction.relatedEventId, messageEvent.event_id);
// SendQueue puts reaction in sending status, as it is first in the queue
assert.equal("update", (await queueObserver.next()).type);
reactionTxnId = reaction.txnId;
}
// 5.2. unset reaction, should redact the previous pending reaction as it has started sending already
let redactionIndex;
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {value: redaction, type, index} = await queueObserver.next();
assert.equal("add", type);
assert.equal(redaction.eventType, "m.room.redaction");
assert.equal(redaction.relatedTxnId, reactionTxnId);
redactionIndex = index;
}
// 5.3. set reaction, should abort the previous pending redaction as it hasn't started sending yet
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 1);
const {index, type} = await queueObserver.next();
assert.equal("remove", type);
assert.equal(redactionIndex, index);
redactionIndex = index;
}
},
}
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js";
export class BaseMessageTile extends SimpleTile {
@ -22,10 +23,10 @@ export class BaseMessageTile extends SimpleTile {
super(options);
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false;
}
get _room() {
return this.getOption("room");
this._reactions = null;
if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions();
}
}
get _mediaRepository() {
@ -97,6 +98,14 @@ export class BaseMessageTile extends SimpleTile {
}
}
updateEntry(entry, param) {
const action = super.updateEntry(entry, param);
if (action.shouldUpdate) {
this._updateReactions();
}
return action;
}
redact(reason, log) {
return this._room.sendRedaction(this._entry.id, reason, log);
}
@ -104,4 +113,81 @@ export class BaseMessageTile extends SimpleTile {
get canRedact() {
return this._powerLevels.canRedactFromSender(this._entry.sender);
}
get reactions() {
if (this.shape !== "redacted") {
return this._reactions;
}
return null;
}
get canReact() {
return this._powerLevels.canSendType("m.reaction");
}
react(key, log = null) {
return this.logger.wrapOrRun(log, "react", async log => {
if (!this.canReact) {
log.set("powerlevel_lacking", true);
return;
}
if (this._entry.haveAnnotation(key)) {
log.set("already_reacted", true);
return;
}
const redaction = this._entry.pendingAnnotations?.get(key)?.redactionEntry;
if (redaction && !redaction.pendingEvent.hasStartedSending) {
log.set("abort_redaction", true);
await redaction.pendingEvent.abort();
} else {
await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log);
}
});
}
redactReaction(key, log = null) {
return this.logger.wrapOrRun(log, "redactReaction", async log => {
if (!this._powerLevels.canRedactFromSender(this._ownMember.userId)) {
log.set("powerlevel_lacking", true);
return;
}
if (!this._entry.haveAnnotation(key)) {
log.set("not_yet_reacted", true);
return;
}
let entry = this._entry.pendingAnnotations?.get(key)?.annotationEntry;
if (!entry) {
entry = await this._timeline.getOwnAnnotationEntry(this._entry.id, key);
}
if (entry) {
await this._room.sendRedaction(entry.id, null, log);
} else {
log.set("no_reaction", true);
}
});
}
toggleReaction(key, log = null) {
return this.logger.wrapOrRun(log, "toggleReaction", async log => {
if (this._entry.haveAnnotation(key)) {
await this.redactReaction(key, log);
} else {
await this.react(key, log);
}
});
}
_updateReactions() {
const {annotations, pendingAnnotations} = this._entry;
if (!annotations && !pendingAnnotations) {
if (this._reactions) {
this._reactions = null;
}
} else {
if (!this._reactions) {
this._reactions = new ReactionsViewModel(this);
}
this._reactions.update(annotations, pendingAnnotations);
}
}
}

View file

@ -33,7 +33,10 @@ export class RoomMemberTile extends SimpleTile {
if (content.avatar_url !== prevContent.avatar_url) {
return `${senderName} changed their avatar`;
} else if (content.displayname !== prevContent.displayname) {
return `${prevContent.displayname} changed their name to ${content.displayname}`;
if (!content.displayname) {
return `${stateKey} removed their name (${prevContent.displayname})`;
}
return `${prevContent.displayname ?? stateKey} changed their name to ${content.displayname}`;
}
} else if (membership === "join") {
return `${targetName} joined the room`;
@ -59,3 +62,28 @@ export class RoomMemberTile extends SimpleTile {
return `${sender} membership changed to ${content.membership}`;
}
}
export function tests() {
return {
"user removes display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
prevContent: {displayname: "foo", membership: "join"},
content: {membership: "join"},
stateKey: "foo@bar.com",
},
});
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
},
"user without display name sets a new display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
prevContent: {membership: "join"},
content: {displayname: "foo", membership: "join" },
stateKey: "foo@bar.com",
},
});
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo");
},
};
}

View file

@ -54,8 +54,7 @@ export class SimpleTile extends ViewModel {
get canAbortSending() {
return this._entry.isPending &&
this._entry.pendingEvent.status !== SendStatus.Sending &&
this._entry.pendingEvent.status !== SendStatus.Sent;
!this._entry.pendingEvent.hasStartedSending;
}
abortSending() {
@ -130,8 +129,12 @@ export class SimpleTile extends ViewModel {
return this._options.room;
}
get _timeline() {
return this._options.timeline;
}
get _powerLevels() {
return this._options.timeline.powerLevels;
return this._timeline.powerLevels;
}
get _ownMember() {

View file

@ -32,13 +32,14 @@ export function tilesCreator(baseOptions) {
const options = Object.assign({entry, emitUpdate}, baseOptions);
if (entry.isGap) {
return new GapTile(options);
} else if (entry.isRedacted) {
return new RedactedTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return new MissingAttachmentTile(options);
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return new RedactedTile(options);
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {

View file

@ -31,9 +31,9 @@ export class NullLogger {
wrapOrRun(item, _, callback) {
if (item) {
item.wrap(null, callback);
return item.wrap(null, callback);
} else {
this.run(null, callback);
return this.run(null, callback);
}
}

View file

@ -333,6 +333,7 @@ export class Sync {
storeNames.roomState,
storeNames.roomMembers,
storeNames.timelineEvents,
storeNames.timelineRelations,
storeNames.timelineFragments,
storeNames.pendingEvents,
storeNames.userIdentities,

View file

@ -17,6 +17,7 @@ limitations under the License.
import {EventEmitter} from "../../utils/EventEmitter.js";
import {RoomSummary} from "./RoomSummary.js";
import {GapWriter} from "./timeline/persistence/GapWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js";
import {Timeline} from "./timeline/Timeline.js";
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
import {WrappedError} from "../error.js"
@ -258,6 +259,7 @@ export class BaseRoom extends EventEmitter {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.pendingEvents,
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineRelations,
this._storage.storeNames.timelineFragments,
]);
let extraGapFillChanges;
@ -266,10 +268,16 @@ export class BaseRoom extends EventEmitter {
// detect remote echos of pending messages in the gap
extraGapFillChanges = await this._writeGapFill(response.chunk, txn, log);
// write new events into gap
const relationWriter = new RelationWriter({
roomId: this._roomId,
fragmentIdComparer: this._fragmentIdComparer,
ownUserId: this._user.id,
});
const gapWriter = new GapWriter({
roomId: this._roomId,
storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer,
relationWriter
});
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log);
} catch (err) {
@ -291,7 +299,7 @@ export class BaseRoom extends EventEmitter {
if (this._timeline) {
// these should not be added if not already there
this._timeline.replaceEntries(gapResult.updatedEntries);
this._timeline.addOrReplaceEntries(gapResult.entries);
this._timeline.addEntries(gapResult.entries);
}
});
}

View file

@ -16,6 +16,8 @@ limitations under the License.
import {BaseRoom} from "./BaseRoom.js";
import {SyncWriter} from "./timeline/persistence/SyncWriter.js";
import {MemberWriter} from "./timeline/persistence/MemberWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js";
import {SendQueue} from "./sending/SendQueue.js";
import {WrappedError} from "../error.js"
import {Heroes} from "./members/Heroes.js";
@ -28,7 +30,17 @@ export class Room extends BaseRoom {
constructor(options) {
super(options);
const {pendingEvents} = options;
this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer});
const relationWriter = new RelationWriter({
roomId: this.id,
fragmentIdComparer: this._fragmentIdComparer,
ownUserId: this._user.id
});
this._syncWriter = new SyncWriter({
roomId: this.id,
fragmentIdComparer: this._fragmentIdComparer,
relationWriter,
memberWriter: new MemberWriter(this.id)
});
this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents});
}
@ -227,7 +239,7 @@ export class Room extends BaseRoom {
if (this._timeline) {
// these should not be added if not already there
this._timeline.replaceEntries(updatedEntries);
this._timeline.addOrReplaceEntries(newEntries);
this._timeline.addEntries(newEntries);
}
if (this._observedEvents) {
this._observedEvents.updateEvents(updatedEntries);
@ -291,7 +303,7 @@ export class Room extends BaseRoom {
/** @public */
sendEvent(eventType, content, attachments, log = null) {
this._platform.logger.wrapOrRun(log, "send", log => {
return this._platform.logger.wrapOrRun(log, "send", log => {
log.set("id", this.id);
return this._sendQueue.enqueueEvent(eventType, content, attachments, log);
});
@ -299,7 +311,7 @@ export class Room extends BaseRoom {
/** @public */
sendRedaction(eventIdOrTxnId, reason, log = null) {
this._platform.logger.wrapOrRun(log, "redact", log => {
return this._platform.logger.wrapOrRun(log, "redact", log => {
log.set("id", this.id);
return this._sendQueue.enqueueRedaction(eventIdOrTxnId, reason, log);
});

View file

@ -21,3 +21,7 @@ export function getPrevContentFromStateEvent(event) {
}
export const REDACTION_TYPE = "m.room.redaction";
export function isRedacted(event) {
return !!event?.unsigned?.redacted_because;
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import {createEnum} from "../../../utils/enum.js";
import {AbortError} from "../../../utils/error.js";
import {REDACTION_TYPE} from "../common.js";
import {getRelationFromContent} from "../timeline/relations.js";
export const SendStatus = createEnum(
"Waiting",
@ -49,10 +50,23 @@ export class PendingEvent {
get remoteId() { return this._data.remoteId; }
get content() { return this._data.content; }
get relatedTxnId() { return this._data.relatedTxnId; }
get relatedEventId() { return this._data.relatedEventId; }
get relatedEventId() {
const relation = getRelationFromContent(this.content);
if (relation) {
// may be null when target is not sent yet, is intended
return relation.event_id;
} else {
return this._data.relatedEventId;
}
}
setRelatedEventId(eventId) {
this._data.relatedEventId = eventId;
const relation = getRelationFromContent(this.content);
if (relation) {
relation.event_id = eventId;
} else {
this._data.relatedEventId = eventId;
}
}
get data() { return this._data; }
@ -102,6 +116,10 @@ export class PendingEvent {
get status() { return this._status; }
get error() { return this._error; }
get hasStartedSending() {
return this._status === SendStatus.Sending || this._status === SendStatus.Sent;
}
get attachmentsTotalBytes() {
return this._attachmentsTotalBytes;
}

View file

@ -19,6 +19,7 @@ import {ConnectionError} from "../../error.js";
import {PendingEvent, SendStatus} from "./PendingEvent.js";
import {makeTxnId, isTxnId} from "../../common.js";
import {REDACTION_TYPE} from "../common.js";
import {getRelationFromContent, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js";
export class SendQueue {
constructor({roomId, storage, hsApi, pendingEvents}) {
@ -38,7 +39,7 @@ export class SendQueue {
const pendingEvent = new PendingEvent({
data,
remove: () => this._removeEvent(pendingEvent),
emitUpdate: () => this._pendingEvents.update(pendingEvent),
emitUpdate: params => this._pendingEvents.update(pendingEvent, params),
attachments
});
return pendingEvent;
@ -156,8 +157,8 @@ export class SendQueue {
}
async _removeEvent(pendingEvent) {
const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) {
let hasEvent = this._pendingEvents.array.indexOf(pendingEvent) !== -1;
if (hasEvent) {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
try {
txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex);
@ -165,7 +166,12 @@ export class SendQueue {
txn.abort();
}
await txn.complete();
this._pendingEvents.remove(idx);
// lookup index after async txn is complete,
// to make sure we're not racing with anything
const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) {
this._pendingEvents.remove(idx);
}
}
pendingEvent.dispose();
}
@ -197,7 +203,26 @@ export class SendQueue {
}
async enqueueEvent(eventType, content, attachments, log) {
await this._enqueueEvent(eventType, content, attachments, null, null, log);
const relation = getRelationFromContent(content);
let relatedTxnId = null;
if (relation) {
if (isTxnId(relation.event_id)) {
relatedTxnId = relation.event_id;
relation.event_id = null;
}
if (relation.rel_type === ANNOTATION_RELATION_TYPE) {
const isAlreadyAnnotating = this._pendingEvents.array.some(pe => {
const r = getRelationFromContent(pe.content);
return pe.eventType === eventType && r && r.key === relation.key &&
(pe.relatedTxnId === relatedTxnId || r.event_id === relation.event_id);
});
if (isAlreadyAnnotating) {
log.set("already_annotating", true);
return;
}
}
}
await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log);
}
async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) {
@ -214,6 +239,14 @@ export class SendQueue {
}
async enqueueRedaction(eventIdOrTxnId, reason, log) {
const isAlreadyRedacting = this._pendingEvents.array.some(pe => {
return pe.eventType === REDACTION_TYPE &&
(pe.relatedTxnId === eventIdOrTxnId || pe.relatedEventId === eventIdOrTxnId);
});
if (isAlreadyRedacting) {
log.set("already_redacting", true);
return;
}
let relatedTxnId;
let relatedEventId;
if (isTxnId(eventIdOrTxnId)) {
@ -284,7 +317,9 @@ export class SendQueue {
// wouldn't be able to detect the remote echo already arrived and end up overwriting the new event
const maxQueueIndex = Math.max(maxStorageQueueIndex, this._currentQueueIndex);
const queueIndex = maxQueueIndex + 1;
const needsEncryption = eventType !== REDACTION_TYPE && !!this._roomEncryption;
const needsEncryption = eventType !== REDACTION_TYPE &&
eventType !== REACTION_TYPE &&
!!this._roomEncryption;
pendingEvent = this._createPendingEvent({
roomId: this._roomId,
queueIndex,
@ -314,9 +349,11 @@ export class SendQueue {
import {HomeServer as MockHomeServer} from "../../../mocks/HomeServer.js";
import {createMockStorage} from "../../../mocks/Storage.js";
import {NullLogger} from "../../../logging/NullLogger.js";
import {ListObserver} from "../../../mocks/ListObserver.js";
import {NullLogger, NullLogItem} from "../../../logging/NullLogger.js";
import {createEvent, withTextBody, withTxnId} from "../../../mocks/event.js";
import {poll} from "../../../mocks/poll.js";
import {createAnnotation} from "../timeline/relations.js";
export function tests() {
const logger = new NullLogger();
@ -350,6 +387,61 @@ export function tests() {
const sendRequest2 = await poll(() => hs.requests.send[1]);
sendRequest2.respond({event_id: event2.event_id});
await poll(() => !queue._isSending);
}
},
"redaction of pending event that hasn't started sending yet aborts it": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
// first, enqueue a message that will be attempted to send, but we don't respond
await queue.enqueueEvent("m.room.message", {body: "hello!"}, null, new NullLogItem());
const observer = new ListObserver();
queue.pendingEvents.subscribe(observer);
await queue.enqueueEvent("m.room.message", {body: "...world"}, null, new NullLogItem());
let txnId;
{
const {type, index, value} = await observer.next();
assert.equal(type, "add");
assert.equal(index, 1);
assert.equal(typeof value.txnId, "string");
txnId = value.txnId;
}
await queue.enqueueRedaction(txnId, null, new NullLogItem());
{
const {type, value, index} = await observer.next();
assert.equal(type, "remove");
assert.equal(index, 1);
assert.equal(txnId, value.txnId);
}
},
"duplicate redaction gets dropped": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
assert.equal(queue.pendingEvents.length, 0);
await queue.enqueueRedaction("!event", null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
await queue.enqueueRedaction("!event", null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
},
"duplicate reaction gets dropped": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
assert.equal(queue.pendingEvents.length, 0);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "👋"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 2);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 2);
},
}
}

View file

@ -0,0 +1,76 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export class PendingAnnotation {
constructor() {
// TODO: use simple member for reaction and redaction as we can't/shouldn't really have more than 2 entries
// this contains both pending annotation entries, and pending redactions of remote annotation entries
this._entries = [];
}
get firstTimestamp() {
return this._entries.reduce((ts, e) => {
if (e.isRedaction) {
return ts;
}
return Math.min(e.timestamp, ts);
}, Number.MAX_SAFE_INTEGER);
}
get annotationEntry() {
return this._entries.find(e => !e.isRedaction);
}
get redactionEntry() {
return this._entries.find(e => e.isRedaction);
}
get count() {
return this._entries.reduce((count, e) => {
return count + (e.isRedaction ? -1 : 1);
}, 0);
}
add(entry) {
this._entries.push(entry);
}
remove(entry) {
const idx = this._entries.indexOf(entry);
if (idx === -1) {
return false;
}
this._entries.splice(idx, 1);
return true;
}
get willAnnotate() {
const lastEntry = this._entries.reduce((lastEntry, e) => {
if (!lastEntry || e.pendingEvent.queueIndex > lastEntry.pendingEvent.queueIndex) {
return e;
}
return lastEntry;
}, null);
if (lastEntry) {
return !lastEntry.isRedaction;
}
return false;
}
get isEmpty() {
return this._entries.length === 0;
}
}

View file

@ -15,22 +15,34 @@ limitations under the License.
*/
export class PowerLevels {
constructor({powerLevelEvent, createEvent, ownUserId}) {
constructor({powerLevelEvent, createEvent, ownUserId, membership}) {
this._plEvent = powerLevelEvent;
this._createEvent = createEvent;
this._ownUserId = ownUserId;
this._membership = membership;
}
canRedactFromSender(userId) {
if (userId === this._ownUserId) {
if (userId === this._ownUserId && this._membership === "join") {
return true;
} else {
return this.canRedact;
}
}
canSendType(eventType) {
return this._myLevel >= this._getEventTypeLevel(eventType);
}
get canRedact() {
return this._getUserLevel(this._ownUserId) >= this._getActionLevel("redact");
return this._myLevel >= this._getActionLevel("redact");
}
get _myLevel() {
if (this._membership !== "join") {
return Number.MIN_SAFE_INTEGER;
}
return this._getUserLevel(this._ownUserId);
}
_getUserLevel(userId) {
@ -59,37 +71,88 @@ export class PowerLevels {
return 50;
}
}
_getEventTypeLevel(eventType) {
const level = this._plEvent?.content.events?.[eventType];
if (typeof level === "number") {
return level;
} else {
const level = this._plEvent?.content.events_default;
if (typeof level === "number") {
return level;
} else {
return 0;
}
}
}
}
export function tests() {
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
const charly = "@charly:hs.tld";
const createEvent = {content: {creator: alice}};
const powerLevelEvent = {content: {
const redactPowerLevelEvent = {content: {
redact: 50,
users: {
[alice]: 50
},
users_default: 0
}};
const eventsPowerLevelEvent = {content: {
events_default: 5,
events: {
"m.room.message": 45,
"m.room.topic": 50,
},
users: {
[alice]: 50,
[bob]: 10
},
users_default: 0
}};
return {
"redact somebody else event with power level event": assert => {
const pl1 = new PowerLevels({powerLevelEvent, ownUserId: alice});
const pl1 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: alice, membership: "join"});
assert.equal(pl1.canRedact, true);
const pl2 = new PowerLevels({powerLevelEvent, ownUserId: bob});
const pl2 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: bob, membership: "join"});
assert.equal(pl2.canRedact, false);
},
"redact somebody else event with create event": assert => {
const pl1 = new PowerLevels({createEvent, ownUserId: alice});
const pl1 = new PowerLevels({createEvent, ownUserId: alice, membership: "join"});
assert.equal(pl1.canRedact, true);
const pl2 = new PowerLevels({createEvent, ownUserId: bob});
const pl2 = new PowerLevels({createEvent, ownUserId: bob, membership: "join"});
assert.equal(pl2.canRedact, false);
},
"redact own event": assert => {
const pl = new PowerLevels({ownUserId: alice});
const pl = new PowerLevels({ownUserId: alice, membership: "join"});
assert.equal(pl.canRedactFromSender(alice), true);
assert.equal(pl.canRedactFromSender(bob), false);
},
"can send event without power levels": assert => {
const pl = new PowerLevels({createEvent, ownUserId: charly, membership: "join"});
assert.equal(pl.canSendType("m.room.message"), true);
},
"can't send any event below events_default": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: charly, membership: "join"});
assert.equal(pl.canSendType("m.foo"), false);
},
"can't send event below events[type]": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: bob, membership: "join"});
assert.equal(pl.canSendType("m.foo"), true);
assert.equal(pl.canSendType("m.room.message"), false);
},
"can send event above or at events[type]": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice, membership: "join"});
assert.equal(pl.canSendType("m.room.message"), true);
assert.equal(pl.canSendType("m.room.topic"), true);
},
"can't redact or send in non-joined room'": assert => {
const pl = new PowerLevels({createEvent, ownUserId: alice, membership: "leave"});
assert.equal(pl.canRedact, false);
assert.equal(pl.canRedactFromSender(alice), false);
assert.equal(pl.canSendType("m.room.message"), false);
},
}
}

View file

@ -15,13 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {Disposables} from "../../../utils/Disposables.js";
import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {RoomMember} from "../members/RoomMember.js";
import {PowerLevels} from "./PowerLevels.js";
import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common.js";
export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) {
@ -64,7 +66,7 @@ export class Timeline {
// as they should only populate once the view subscribes to it
// if they are populated already, the sender profile would be empty
this._powerLevels = await this._loadPowerLevels(txn);
this._powerLevels = await this._loadPowerLevels(membership, txn);
// 30 seems to be a good amount to fill the entire screen
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log));
try {
@ -76,78 +78,115 @@ export class Timeline {
// txn should be assumed to have finished here, as decryption will close it.
}
async _loadPowerLevels(txn) {
async _loadPowerLevels(membership, txn) {
// TODO: update power levels as state is updated
const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", "");
if (powerLevelsState) {
return new PowerLevels({
powerLevelEvent: powerLevelsState.event,
ownUserId: this._ownMember.userId
ownUserId: this._ownMember.userId,
membership
});
}
const createState = await txn.roomState.get(this._roomId, "m.room.create", "");
if (createState) {
return new PowerLevels({
createEvent: createState.event,
ownUserId: this._ownMember.userId
ownUserId: this._ownMember.userId,
membership
});
} else {
return new PowerLevels({ownUserId: this._ownMember.userId});
return new PowerLevels({ownUserId: this._ownMember.userId, membership});
}
}
_setupEntries(timelineEntries) {
this._remoteEntries.setManySorted(timelineEntries);
if (this._pendingEvents) {
this._localEntries = new MappedList(this._pendingEvents, pe => {
const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock});
this._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));
});
this._localEntries = new AsyncMappedList(this._pendingEvents,
pe => this._mapPendingEventToEntry(pe),
(pee, params) => {
// is sending but redacted, who do we detect that here to remove the relation?
pee.notifyUpdate(params);
},
pee => this._applyAndEmitLocalRelationChange(pee, target => target.removeLocalRelation(pee))
);
} else {
this._localEntries = new ObservableArray();
}
this._allEntries = new ConcatList(this._remoteEntries, this._localEntries);
}
_applyAndEmitLocalRelationChange(pe, updater) {
async _mapPendingEventToEntry(pe) {
// we load the redaction target for pending events,
// so if we are redacting a relation, we can pass the redaction
// to the relation target and the removal of the relation can
// be taken into account for local echo.
let redactingEntry;
if (pe.eventType === REDACTION_TYPE) {
redactingEntry = await this._getOrLoadEntry(pe.relatedTxnId, pe.relatedEventId);
}
const pee = new PendingEventEntry({
pendingEvent: pe, member: this._ownMember,
clock: this._clock, redactingEntry
});
this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee));
return pee;
}
_applyAndEmitLocalRelationChange(pee, updater) {
// this is the contract of findAndUpdate, used in _findAndUpdateRelatedEntry
const updateOrFalse = e => {
const params = updater(e);
return params ? params : false;
};
this._findAndUpdateRelatedEntry(pee.pendingEvent.relatedTxnId, pee.relatedEventId, updateOrFalse);
// also look for a relation target to update with this redaction
if (pee.redactingEntry) {
// redactingEntry might be a PendingEventEntry or an EventEntry, so don't assume pendingEvent
const relatedTxnId = pee.redactingEntry.pendingEvent?.relatedTxnId;
this._findAndUpdateRelatedEntry(relatedTxnId, pee.redactingEntry.relatedEventId, updateOrFalse);
}
}
_findAndUpdateRelatedEntry(relatedTxnId, relatedEventId, updateOrFalse) {
let found = false;
// first, look in local entries based on txn id
if (pe.relatedTxnId) {
const found = this._localEntries.findAndUpdate(
e => e.id === pe.relatedTxnId,
if (relatedTxnId) {
found = this._localEntries.findAndUpdate(
e => e.id === relatedTxnId,
updateOrFalse,
);
if (found) {
return;
}
}
// now look in remote entries based on event id
if (pe.relatedEventId) {
// if not found here, look in remote entries based on event id
if (!found && relatedEventId) {
this._remoteEntries.findAndUpdate(
e => e.id === pe.relatedEventId,
e => e.id === relatedEventId,
updateOrFalse
);
}
}
updateOwnMember(member) {
this._ownMember = member;
async getOwnAnnotationEntry(targetId, key) {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineRelations,
]);
const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE);
for (const relation of relations) {
const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId);
if (annotation && annotation.event.sender === this._ownMember.userId && getRelation(annotation.event).key === key) {
const eventEntry = new EventEntry(annotation, this._fragmentIdComparer);
this._addLocalRelationsToNewRemoteEntries([eventEntry]);
return eventEntry;
}
}
return null;
}
replaceEntries(entries) {
this._addLocalRelationsToNewRemoteEntries(entries);
for (const entry of entries) {
this._remoteEntries.update(entry);
}
/** @package */
updateOwnMember(member) {
this._ownMember = member;
}
_addLocalRelationsToNewRemoteEntries(entries) {
@ -172,11 +211,30 @@ export class Timeline {
// no need to emit here as this entry is about to be added
relationTarget?.addLocalRelation(pee);
}
if (pee.redactingEntry) {
const eventId = pee.redactingEntry.relatedEventId;
const relationTarget = entries.find(e => e.id === eventId);
relationTarget?.addLocalRelation(pee);
}
}
}
// used in replaceEntries
static _entryUpdater(existingEntry, entry) {
entry.updateFrom(existingEntry);
return entry;
}
/** @package */
replaceEntries(entries) {
this._addLocalRelationsToNewRemoteEntries(entries);
for (const entry of entries) {
this._remoteEntries.getAndUpdate(entry, Timeline._entryUpdater);
}
}
/** @package */
addOrReplaceEntries(newEntries) {
addEntries(newEntries) {
this._addLocalRelationsToNewRemoteEntries(newEntries);
this._remoteEntries.setManySorted(newEntries);
}
@ -203,13 +261,39 @@ export class Timeline {
));
try {
const entries = await readerRequest.complete();
this.addOrReplaceEntries(entries);
this.addEntries(entries);
return entries.length < amount;
} finally {
this._disposables.disposeTracked(readerRequest);
}
}
async _getOrLoadEntry(txnId, eventId) {
if (txnId) {
// also look for redacting relation in pending events, in case the target is already being sent
for (const p of this._localEntries) {
if (p.id === txnId) {
return p;
}
}
}
if (eventId) {
const loadedEntry = this.getByEventId(eventId);
if (loadedEntry) {
return loadedEntry;
} else {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
]);
const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
if (redactionTargetEntry) {
return new EventEntry(redactionTargetEntry, this._fragmentIdComparer);
}
}
}
return null;
}
getByEventId(eventId) {
for (let i = 0; i < this._remoteEntries.length; i += 1) {
const entry = this._remoteEntries.get(i);
@ -257,37 +341,48 @@ export class Timeline {
}
import {FragmentIdComparer} from "./FragmentIdComparer.js";
import {poll} from "../../../mocks/poll.js";
import {Clock as MockClock} from "../../../mocks/Clock.js";
import {createMockStorage} from "../../../mocks/Storage.js";
import {createEvent, withTextBody, withSender} from "../../../mocks/event.js";
import {ListObserver} from "../../../mocks/ListObserver.js";
import {createEvent, withTextBody, withContent, withSender} from "../../../mocks/event.js";
import {NullLogItem} from "../../../logging/NullLogger.js";
import {EventEntry} from "./entries/EventEntry.js";
import {User} from "../../User.js";
import {PendingEvent} from "../sending/PendingEvent.js";
import {createAnnotation} from "./relations.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
function getIndexFromIterable(it, n) {
let i = 0;
for (const item of it) {
if (i === n) {
return item;
}
i += 1;
}
throw new Error("not enough items in iterable");
}
return {
"adding or replacing entries before subscribing to entries does not loose local relations": async assert => {
"adding or replacing entries before subscribing to entries does not lose local relations": async assert => {
const pendingEvents = new ObservableArray();
const timeline = new Timeline({
roomId,
storage: await createMockStorage(),
closeCallback: () => {},
fragmentIdComparer,
pendingEvents,
clock: new MockClock(),
});
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
// 1. load timeline
await timeline.load(new User("@alice:hs.tld"), "join", new NullLogItem());
// 2. test replaceEntries and addOrReplaceEntries don't fail
const event1 = withTextBody("hi!", withSender("@bob:hs.tld", createEvent("m.room.message", "!abc")));
await timeline.load(new User(alice), "join", new NullLogItem());
// 2. test replaceEntries and addEntries don't fail
const event1 = withTextBody("hi!", withSender(bob, createEvent("m.room.message", "!abc")));
const entry1 = new EventEntry({event: event1, fragmentId: 1, eventIndex: 1}, fragmentIdComparer);
timeline.replaceEntries([entry1]);
const event2 = withTextBody("hi bob!", withSender("@alice:hs.tld", createEvent("m.room.message", "!def")));
const event2 = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!def")));
const entry2 = new EventEntry({event: event2, fragmentId: 1, eventIndex: 2}, fragmentIdComparer);
timeline.addOrReplaceEntries([entry2]);
timeline.addEntries([entry2]);
// 3. add local relation (redaction)
pendingEvents.append(new PendingEvent({data: {
roomId,
@ -298,9 +393,213 @@ export function tests() {
relatedEventId: event2.event_id
}}));
// 4. subscribe (it's now safe to iterate timeline.entries)
timeline.entries.subscribe({});
timeline.entries.subscribe(new ListObserver());
// 5. check the local relation got correctly aggregated
assert.equal(Array.from(timeline.entries)[0].isRedacting, true);
const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting);
assert.equal(locallyRedacted, true);
},
"add and remove local reaction, and cancel again": async assert => {
// 1. setup timeline with message
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc")));
timeline.addEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]);
let entry = getIndexFromIterable(timeline.entries, 0);
// 2. add local reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.reaction",
txnId: "t123",
content: entry.annotate("👋"),
relatedEventId: entry.id
}}));
await poll(() => timeline.entries.length === 2);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
const reactionEntry = getIndexFromIterable(timeline.entries, 1);
// 3. add redaction to timeline
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 2,
eventType: "m.room.redaction",
txnId: "t456",
content: {},
relatedTxnId: reactionEntry.id
}}));
// TODO: await nextUpdate here with ListObserver, to ensure entry emits an update when pendingAnnotations changes
await poll(() => timeline.entries.length === 3);
assert.equal(entry.pendingAnnotations.get("👋").count, 0);
// 4. cancel redaction
pendingEvents.remove(1);
await poll(() => timeline.entries.length === 2);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
// 5. cancel reaction
pendingEvents.remove(0);
await poll(() => timeline.entries.length === 1);
assert(!entry.pendingAnnotations);
},
"getOwnAnnotationEntry": async assert => {
const messageId = "!abc";
const reactionId = "!def";
// 1. put event and reaction into storage
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({
event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)),
fragmentId: 1, eventIndex: 1, roomId
});
txn.timelineRelations.add(roomId, messageId, ANNOTATION_RELATION_TYPE, reactionId);
await txn.complete();
// 2. setup the timeline
const timeline = new Timeline({roomId, storage, closeCallback: () => {},
fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
// 3. get the own annotation out
const reactionEntry = await timeline.getOwnAnnotationEntry(messageId, "👋");
assert.equal(reactionEntry.id, reactionId);
assert.equal(reactionEntry.relation.key, "👋");
},
"remote reaction": async assert => {
const messageEntry = new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)),
fragmentId: 1, eventIndex: 2, roomId,
annotations: { // aggregated like RelationWriter would
"👋": {count: 1, me: true, firstTimestamp: 0}
},
}, fragmentIdComparer);
// 2. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 3. add message to timeline
timeline.addEntries([messageEntry]);
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry, messageEntry);
assert.equal(entry.annotations["👋"].count, 1);
},
"remove remote reaction": async assert => {
// 1. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 2. add message and reaction to timeline
const messageEntry = new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)),
fragmentId: 1, eventIndex: 2, roomId,
}, fragmentIdComparer);
const reactionEntry = new EventEntry({
event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)),
fragmentId: 1, eventIndex: 3, roomId
}, fragmentIdComparer);
timeline.addEntries([messageEntry, reactionEntry]);
// 3. redact reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.room.redaction",
txnId: "t123",
content: {},
relatedEventId: reactionEntry.id
}}));
await poll(() => timeline.entries.length >= 3);
assert.equal(messageEntry.pendingAnnotations.get("👋").count, -1);
},
"local reaction gets applied after remote echo is added to timeline": async assert => {
const messageEntry = new EventEntry({event: withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))),
fragmentId: 1, eventIndex: 2}, fragmentIdComparer);
// 1. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 2. add local reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.reaction",
txnId: "t123",
content: messageEntry.annotate("👋"),
relatedEventId: messageEntry.id
}}));
await poll(() => timeline.entries.length === 1);
// 3. add remote reaction target
timeline.addEntries([messageEntry]);
await poll(() => timeline.entries.length === 2);
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry, messageEntry);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
},
"local reaction removal gets applied after remote echo is added to timeline with reaction not loaded": async assert => {
const messageId = "!abc";
const reactionId = "!def";
// 1. put reaction in storage
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({
event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)),
fragmentId: 1, eventIndex: 3, roomId
});
await txn.complete();
// 2. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage, closeCallback: () => {},
fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 3. add local redaction for reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.room.redaction",
txnId: "t123",
content: {},
relatedEventId: reactionId
}}));
await poll(() => timeline.entries.length === 1);
// 4. add reaction target
timeline.addEntries([new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", messageId, alice)),
fragmentId: 1, eventIndex: 2}, fragmentIdComparer)
]);
await poll(() => timeline.entries.length === 2);
// 5. check that redaction was linked to reaction target
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry.pendingAnnotations.get("👋").count, -1);
},
"decrypted entry preserves content when receiving other update without decryption": async assert => {
// 1. create encrypted and decrypted entry
const encryptedEntry = new EventEntry({
event: withContent({ciphertext: "abc"}, createEvent("m.room.encrypted", "!abc", alice)),
fragmentId: 1, eventIndex: 1, roomId
}, fragmentIdComparer);
const decryptedEntry = encryptedEntry.clone();
decryptedEntry.setDecryptionResult({
event: withTextBody("hi bob!", createEvent("m.room.message", encryptedEntry.id, encryptedEntry.sender))
});
// 2. setup the timeline
const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {},
fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()});
await timeline.load(new User(alice), "join", new NullLogItem());
timeline.addEntries([decryptedEntry]);
const observer = new ListObserver();
timeline.entries.subscribe(observer);
// 3. replace the entry with one that is not decrypted
// (as would happen when receiving a reaction,
// as it does not rerun the decryption)
// and check that the decrypted content is preserved
timeline.replaceEntries([encryptedEntry]);
const {value, type} = await observer.next();
assert.equal(type, "update");
assert.equal(value.eventType, "m.room.message");
assert.equal(value.content.body, "hi bob!");
}
}
};
}

View file

@ -47,4 +47,6 @@ export class BaseEntry {
asEventKey() {
return new EventKey(this.fragmentId, this.entryIndex);
}
updateFrom() {}
}

View file

@ -16,11 +16,16 @@ limitations under the License.
import {BaseEntry} from "./BaseEntry.js";
import {REDACTION_TYPE} from "../../common.js";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js";
/** Deals mainly with local echo for relations and redactions,
* so it is shared between PendingEventEntry and EventEntry */
export class BaseEventEntry extends BaseEntry {
constructor(fragmentIdComparer) {
super(fragmentIdComparer);
this._pendingRedactions = null;
this._pendingAnnotations = null;
}
get isRedacting() {
@ -31,6 +36,10 @@ export class BaseEventEntry extends BaseEntry {
return this.isRedacting;
}
get isRedaction() {
return this.eventType === REDACTION_TYPE;
}
get redactionReason() {
if (this._pendingRedactions) {
return this._pendingRedactions[0].content?.reason;
@ -39,11 +48,11 @@ export class BaseEventEntry extends BaseEntry {
}
/**
aggregates local relation.
aggregates local relation or local redaction of remote relation.
@return [string] returns the name of the field that has changed, if any
*/
addLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE) {
if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id)) {
if (!this._pendingRedactions) {
this._pendingRedactions = [];
}
@ -51,15 +60,24 @@ export class BaseEventEntry extends BaseEntry {
if (this._pendingRedactions.length === 1) {
return "isRedacted";
}
} else {
const relationEntry = entry.redactingEntry || entry;
if (relationEntry.isRelatedToId(this.id)) {
if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) {
if (this._addPendingAnnotation(entry)) {
return "pendingAnnotations";
}
}
}
}
}
/**
deaggregates local relation.
deaggregates local relation or a local redaction of a remote relation.
@return [string] returns the name of the field that has changed, if any
*/
removeLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE && this._pendingRedactions) {
if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id) && this._pendingRedactions) {
const countBefore = this._pendingRedactions.length;
this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry);
if (this._pendingRedactions.length === 0) {
@ -68,9 +86,50 @@ export class BaseEventEntry extends BaseEntry {
return "isRedacted";
}
}
} else {
const relationEntry = entry.redactingEntry || entry;
if (relationEntry.isRelatedToId(this.id)) {
if (relationEntry.relation?.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) {
if (this._removePendingAnnotation(entry)) {
return "pendingAnnotations";
}
}
}
}
}
_addPendingAnnotation(entry) {
if (!this._pendingAnnotations) {
this._pendingAnnotations = new Map();
}
const {key} = (entry.redactingEntry || entry).relation;
if (key) {
let annotation = this._pendingAnnotations.get(key);
if (!annotation) {
annotation = new PendingAnnotation();
this._pendingAnnotations.set(key, annotation);
}
annotation.add(entry);
return true;
}
return false;
}
_removePendingAnnotation(entry) {
const {key} = (entry.redactingEntry || entry).relation;
if (key) {
let annotation = this._pendingAnnotations.get(key);
if (annotation.remove(entry) && annotation.isEmpty) {
this._pendingAnnotations.delete(key);
}
if (this._pendingAnnotations.size === 0) {
this._pendingAnnotations = null;
}
return true;
}
return false;
}
async abortPendingRedaction() {
if (this._pendingRedactions) {
for (const pee of this._pendingRedactions) {
@ -80,4 +139,46 @@ export class BaseEventEntry extends BaseEntry {
// so don't clear _pendingRedactions here
}
}
get pendingRedaction() {
if (this._pendingRedactions) {
return this._pendingRedactions[0];
}
return null;
}
annotate(key) {
return createAnnotation(this.id, key);
}
/** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */
isRelatedToId(id) {
return id && this.relatedEventId === id;
}
haveAnnotation(key) {
const haveRemoteReaction = this.annotations?.[key]?.me || false;
const pendingAnnotation = this.pendingAnnotations?.get(key);
const willAnnotate = pendingAnnotation?.willAnnotate || false;
/*
We have an annotation in these case:
- remote annotation with me, no pending
- remote annotation with me, pending redaction and then annotation
- pending annotation without redaction after it
*/
return (haveRemoteReaction && (!pendingAnnotation || willAnnotate)) ||
(!haveRemoteReaction && willAnnotate);
}
get relation() {
return getRelationFromContent(this.content);
}
get pendingAnnotations() {
return this._pendingAnnotations;
}
get annotations() {
return null; //overwritten in EventEntry
}
}

View file

@ -15,7 +15,8 @@ limitations under the License.
*/
import {BaseEventEntry} from "./BaseEventEntry.js";
import {getPrevContentFromStateEvent} from "../../common.js";
import {getPrevContentFromStateEvent, isRedacted} from "../../common.js";
import {getRelatedEventId} from "../relations.js";
export class EventEntry extends BaseEventEntry {
constructor(eventEntry, fragmentIdComparer) {
@ -27,11 +28,20 @@ export class EventEntry extends BaseEventEntry {
clone() {
const clone = new EventEntry(this._eventEntry, this._fragmentIdComparer);
clone._decryptionResult = this._decryptionResult;
clone._decryptionError = this._decryptionError;
clone.updateFrom(this);
return clone;
}
updateFrom(other) {
super.updateFrom(other);
if (other._decryptionResult && !this._decryptionResult) {
this._decryptionResult = other._decryptionResult;
}
if (other._decryptionError && !this._decryptionError) {
this._decryptionError = other._decryptionError;
}
}
get event() {
return this._eventEntry.event;
}
@ -110,11 +120,11 @@ export class EventEntry extends BaseEventEntry {
}
get relatedEventId() {
return this._eventEntry.event.redacts;
return getRelatedEventId(this.event);
}
get isRedacted() {
return super.isRedacted || !!this._eventEntry.event.unsigned?.redacted_because;
return super.isRedacted || isRedacted(this._eventEntry.event);
}
get redactionReason() {
@ -125,4 +135,93 @@ export class EventEntry extends BaseEventEntry {
// fall back to local echo reason
return super.redactionReason;
}
get annotations() {
return this._eventEntry.annotations;
}
}
import {withTextBody, withContent, createEvent} from "../../../../mocks/event.js";
import {Clock as MockClock} from "../../../../mocks/Clock.js";
import {PendingEventEntry} from "./PendingEventEntry.js";
import {PendingEvent} from "../../sending/PendingEvent.js";
import {createAnnotation} from "../relations.js";
export function tests() {
let queueIndex = 0;
const clock = new MockClock();
function addPendingReaction(target, key) {
queueIndex += 1;
target.addLocalRelation(new PendingEventEntry({
pendingEvent: new PendingEvent({data: {
eventType: "m.reaction",
content: createAnnotation(target.id, key),
queueIndex,
txnId: `t${queueIndex}`
}}),
clock
}));
return target;
}
function addPendingRedaction(target, key) {
const pendingReaction = target.pendingAnnotations?.get(key)?.annotationEntry;
let redactingEntry = pendingReaction;
// make up a remote entry if we don't have a pending reaction and have an aggregated remote entry
if (!pendingReaction && target.annotations[key].me) {
redactingEntry = new EventEntry({
event: withContent(createAnnotation(target.id, key), createEvent("m.reaction", "!def"))
});
}
queueIndex += 1;
target.addLocalRelation(new PendingEventEntry({
pendingEvent: new PendingEvent({data: {
eventType: "m.room.redaction",
relatedTxnId: pendingReaction ? pendingReaction.id : null,
relatedEventId: pendingReaction ? null : redactingEntry.id,
queueIndex,
txnId: `t${queueIndex}`
}}),
redactingEntry,
clock
}));
return target;
}
function remoteAnnotation(key, me, count, obj = {}) {
obj[key] = {me, count};
return obj;
}
return {
// testing it here because parent class always assumes annotations is null
"haveAnnotation": assert => {
const msgEvent = withTextBody("hi!", createEvent("m.room.message", "!abc"));
const e1 = new EventEntry({event: msgEvent});
assert.equal(false, e1.haveAnnotation("🚀"));
const e2 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", false, 1)});
assert.equal(false, e2.haveAnnotation("🚀"));
const e3 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)});
assert.equal(true, e3.haveAnnotation("🚀"));
const e4 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 2)});
assert.equal(true, e4.haveAnnotation("🚀"));
const e5 = addPendingReaction(new EventEntry({event: msgEvent}), "🚀");
assert.equal(true, e5.haveAnnotation("🚀"));
const e6 = addPendingRedaction(new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}), "🚀");
assert.equal(false, e6.haveAnnotation("🚀"));
const e7 = addPendingReaction(
addPendingRedaction(
new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}),
"🚀"),
"🚀");
assert.equal(true, e7.haveAnnotation("🚀"));
const e8 = addPendingRedaction(
addPendingReaction(
new EventEntry({event: msgEvent}),
"🚀"),
"🚀");
assert.equal(false, e8.haveAnnotation("🚀"));
}
}
}

View file

@ -18,12 +18,16 @@ import {PENDING_FRAGMENT_ID} from "./BaseEntry.js";
import {BaseEventEntry} from "./BaseEventEntry.js";
export class PendingEventEntry extends BaseEventEntry {
constructor({pendingEvent, member, clock}) {
constructor({pendingEvent, member, clock, redactingEntry}) {
super(null);
this._pendingEvent = pendingEvent;
/** @type {RoomMember} */
this._member = member;
this._clock = clock;
// try to come up with a timestamp that is around construction time and
// will be roughly sorted by queueIndex, so it can be used to as a secondary
// sorting dimension for reactions
this._timestamp = clock.now() - (100 - pendingEvent.queueIndex);
this._redactingEntry = redactingEntry;
}
get fragmentId() {
@ -63,7 +67,7 @@ export class PendingEventEntry extends BaseEventEntry {
}
get timestamp() {
return this._clock.now();
return this._timestamp;
}
get isPending() {
@ -82,7 +86,18 @@ export class PendingEventEntry extends BaseEventEntry {
}
isRelatedToId(id) {
if (id && id === this._pendingEvent.relatedTxnId) {
return true;
}
return super.isRelatedToId(id);
}
get relatedEventId() {
return this._pendingEvent.relatedEventId;
}
get redactingEntry() {
return this._redactingEntry;
}
}

View file

@ -14,18 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {RelationWriter} from "./RelationWriter.js";
import {EventKey} from "../EventKey.js";
import {EventEntry} from "../entries/EventEntry.js";
import {createEventEntry, directionalAppend} from "./common.js";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
export class GapWriter {
constructor({roomId, storage, fragmentIdComparer}) {
constructor({roomId, storage, fragmentIdComparer, relationWriter}) {
this._roomId = roomId;
this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer;
this._relationWriter = new RelationWriter(roomId, fragmentIdComparer);
this._relationWriter = relationWriter;
}
// events is in reverse-chronological order (last event comes at index 0) if backwards
async _findOverlappingEvents(fragmentEntry, events, txn, log) {
@ -120,13 +119,14 @@ export class GapWriter {
eventStorageEntry.displayName = member.displayName;
eventStorageEntry.avatarUrl = member.avatarUrl;
}
// this will modify eventStorageEntry if it is a relation target
const updatedRelationTargetEntries = await this._relationWriter.writeGapRelation(eventStorageEntry, direction, txn, log);
if (updatedRelationTargetEntries) {
updatedEntries.push(...updatedRelationTargetEntries);
}
txn.timelineEvents.insert(eventStorageEntry);
const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer);
directionalAppend(entries, eventEntry, direction);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log);
if (updatedRelationTargetEntry) {
updatedEntries.push(updatedRelationTargetEntry);
}
}
return {entries, updatedEntries};
}

View file

@ -15,59 +15,211 @@ limitations under the License.
*/
import {EventEntry} from "../entries/EventEntry.js";
import {REDACTION_TYPE} from "../../common.js";
import {REDACTION_TYPE, isRedacted} from "../../common.js";
import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js";
export class RelationWriter {
constructor(roomId, fragmentIdComparer) {
constructor({roomId, ownUserId, fragmentIdComparer}) {
this._roomId = roomId;
this._ownUserId = ownUserId;
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);
const {relatedEventId} = sourceEntry;
if (relatedEventId) {
const relation = getRelation(sourceEntry.event);
if (relation) {
txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id);
}
const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId);
if (target) {
if (this._applyRelation(sourceEntry, target, log)) {
txn.timelineEvents.update(target);
return new EventEntry(target, this._fragmentIdComparer);
const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log);
if (updatedStorageEntries) {
return updatedStorageEntries.map(e => {
txn.timelineEvents.update(e);
return new EventEntry(e, this._fragmentIdComparer);
});
}
}
}
return;
return null;
}
_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];
/**
* @param {Object} storageEntry the event object, as it will be stored in storage.
* Will be modified (but not written to storage) in case this event is
* a relation target for which we've previously received relations.
* @param {Direction} direction of the gap fill
* */
async writeGapRelation(storageEntry, direction, txn, log) {
const sourceEntry = new EventEntry(storageEntry, this._fragmentIdComparer);
const result = await this.writeRelation(sourceEntry, txn, log);
// when back-paginating, it can also happen that we've received relations
// for this event before, which now upon receiving the target need to be aggregated.
if (direction.isBackward && !isRedacted(storageEntry.event)) {
const relations = await txn.timelineRelations.getAllForTarget(this._roomId, sourceEntry.id);
if (relations.length) {
for (const r of relations) {
const relationStorageEntry = await txn.timelineEvents.getByEventId(this._roomId, r.sourceEventId);
if (relationStorageEntry) {
const relationEntry = new EventEntry(relationStorageEntry, this._fragmentIdComparer);
await this._applyRelation(relationEntry, storageEntry, txn, log);
}
}
}
}
const {content} = targetEvent;
const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type];
return result;
}
/**
* @param {EventEntry} sourceEntry
* @param {Object} targetStorageEntry event entry as stored in the timelineEvents store
* @return {[Object]} array of event storage entries that have been updated
* */
async _applyRelation(sourceEntry, targetStorageEntry, txn, log) {
if (sourceEntry.eventType === REDACTION_TYPE) {
return log.wrap("redact", async log => {
const redactedEvent = targetStorageEntry.event;
const relation = getRelation(redactedEvent); // get this before redacting
const redacted = this._applyRedaction(sourceEntry.event, targetStorageEntry, txn, log);
if (redacted) {
const updated = [targetStorageEntry];
if (relation) {
const relationTargetStorageEntry = await this._reaggregateRelation(redactedEvent, relation, txn, log);
if (relationTargetStorageEntry) {
updated.push(relationTargetStorageEntry);
}
}
return updated;
}
return null;
});
} else {
const relation = getRelation(sourceEntry.event);
if (relation && !isRedacted(targetStorageEntry.event)) {
const relType = relation.rel_type;
if (relType === ANNOTATION_RELATION_TYPE) {
const aggregated = log.wrap("react", log => {
return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log);
});
if (aggregated) {
return [targetStorageEntry];
}
}
}
}
return null;
}
_applyRedaction(redactionEvent, redactedStorageEntry, txn, log) {
const redactedEvent = redactedStorageEntry.event;
log.set("redactionId", redactionEvent.event_id);
log.set("id", redactedEvent.event_id);
const relation = getRelation(redactedEvent);
if (relation) {
txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id);
}
// check if we're the target of a relation and remove all relations then as well
txn.timelineRelations.removeAllForTarget(this._roomId, redactedEvent.event_id);
for (const key of Object.keys(redactedEvent)) {
if (!_REDACT_KEEP_KEY_MAP[key]) {
delete redactedEvent[key];
}
}
const {content} = redactedEvent;
const keepMap = _REDACT_KEEP_CONTENT_MAP[redactedEvent.type];
for (const key of Object.keys(content)) {
if (!keepMap?.[key]) {
delete content[key];
}
}
targetEvent.unsigned = targetEvent.unsigned || {};
targetEvent.unsigned.redacted_because = redactionEvent;
redactedEvent.unsigned = redactedEvent.unsigned || {};
redactedEvent.unsigned.redacted_because = redactionEvent;
delete redactedStorageEntry.annotations;
return true;
}
_aggregateAnnotation(annotationEvent, targetStorageEntry, log) {
// TODO: do we want to verify it is a m.reaction event somehow?
const relation = getRelation(annotationEvent);
if (!relation) {
return false;
}
let {annotations} = targetStorageEntry;
if (!annotations) {
targetStorageEntry.annotations = annotations = {};
}
let annotation = annotations[relation.key];
if (!annotation) {
annotations[relation.key] = annotation = {
count: 0,
me: false,
firstTimestamp: Number.MAX_SAFE_INTEGER
};
}
const sentByMe = annotationEvent.sender === this._ownUserId;
annotation.me = annotation.me || sentByMe;
annotation.count += 1;
annotation.firstTimestamp = Math.min(
annotation.firstTimestamp,
annotationEvent.origin_server_ts
);
return true;
}
async _reaggregateRelation(redactedRelationEvent, redactedRelation, txn, log) {
if (redactedRelation.rel_type === ANNOTATION_RELATION_TYPE) {
return log.wrap("reaggregate annotations", log => this._reaggregateAnnotation(
redactedRelation.event_id,
redactedRelation.key,
txn, log
));
}
return null;
}
async _reaggregateAnnotation(targetId, key, txn, log) {
const target = await txn.timelineEvents.getByEventId(this._roomId, targetId);
if (!target) {
return null;
}
log.set("id", targetId);
const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE);
log.set("relations", relations.length);
delete target.annotations[key];
if (isObjectEmpty(target.annotations)) {
delete target.annotations;
}
await Promise.all(relations.map(async relation => {
const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId);
if (!annotation) {
log.log({l: "missing annotation", id: relation.sourceEventId});
}
if (getRelation(annotation.event).key === key) {
this._aggregateAnnotation(annotation.event, target, log);
}
}));
return target;
}
}
function isObjectEmpty(obj) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
return false;
}
}
return true;
}
// copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd
@ -99,3 +251,136 @@ const _REDACT_KEEP_CONTENT_MAP = {
'm.room.aliases': {'aliases': 1},
};
// end of matrix-js-sdk code
import {createMockStorage} from "../../../../mocks/Storage.js";
import {createEvent, withTextBody, withRedacts, withContent} from "../../../../mocks/event.js";
import {createAnnotation} from "../relations.js";
import {FragmentIdComparer} from "../FragmentIdComparer.js";
import {NullLogItem} from "../../../../logging/NullLogger.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
return {
"apply redaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reason = "nonsense, cats are the best!";
const redaction = withRedacts(event.event_id, reason, createEvent("m.room.redaction", "!def", alice));
const redactionEntry = new EventEntry({fragmentId: 1, eventIndex: 3, event: redaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: bob, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
const updatedEntries = await relationWriter.writeRelation(redactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const redactedMessage = updatedEntries[0];
assert.equal(redactedMessage.id, "!abc");
assert.equal(redactedMessage.content.body, undefined);
assert.equal(redactedMessage.redactionReason, reason);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert.equal(storedMessage.event.content.body, undefined);
assert.equal(storedMessage.event.unsigned.redacted_because.content.reason, reason);
},
"aggregate reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
reaction.origin_server_ts = 5;
const reactionEntry = new EventEntry({event: reaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
const updatedEntries = await relationWriter.writeRelation(reactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const reactedMessage = updatedEntries[0];
assert.equal(reactedMessage.id, "!abc");
const annotation = reactedMessage.annotations["🐶"];
assert.equal(annotation.me, true);
assert.equal(annotation.count, 1);
assert.equal(annotation.firstTimestamp, 5);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert(storedMessage.annotations["🐶"]);
},
"aggregate second reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reaction1 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
reaction1.origin_server_ts = 5;
const reaction1Entry = new EventEntry({event: reaction1, roomId}, fragmentIdComparer);
const reaction2 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
reaction2.origin_server_ts = 10;
const reaction2Entry = new EventEntry({event: reaction2, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
await relationWriter.writeRelation(reaction1Entry, txn, new NullLogItem());
const updatedEntries = await relationWriter.writeRelation(reaction2Entry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const reactedMessage = updatedEntries[0];
assert.equal(reactedMessage.id, "!abc");
const annotation = reactedMessage.annotations["🐶"];
assert.equal(annotation.me, true);
assert.equal(annotation.count, 2);
assert.equal(annotation.firstTimestamp, 5);
},
"redact second reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const myReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
myReaction.origin_server_ts = 5;
const bobReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
bobReaction.origin_server_ts = 10;
const myReactionRedaction = withRedacts(myReaction.event_id, "", createEvent("m.room.redaction", "!pol", alice));
const myReactionEntry = new EventEntry({event: myReaction, roomId}, fragmentIdComparer);
const bobReactionEntry = new EventEntry({event: bobReaction, roomId}, fragmentIdComparer);
const myReactionRedactionEntry = new EventEntry({event: myReactionRedaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReaction, roomId});
await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem());
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 4, event: bobReaction, roomId});
await relationWriter.writeRelation(bobReactionEntry, txn, new NullLogItem());
const updatedEntries = await relationWriter.writeRelation(myReactionRedactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 2);
const redactedReaction = updatedEntries[0];
assert.equal(redactedReaction.id, "!def");
const reaggregatedMessage = updatedEntries[1];
assert.equal(reaggregatedMessage.id, "!abc");
const annotation = reaggregatedMessage.annotations["🐶"];
assert.equal(annotation.me, false);
assert.equal(annotation.count, 1);
assert.equal(annotation.firstTimestamp, 10);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert.equal(storedMessage.annotations["🐶"].count, 1);
},
}
}

View file

@ -20,8 +20,6 @@ import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.js";
import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
import {MemberWriter} from "./MemberWriter.js";
import {RelationWriter} from "./RelationWriter.js";
// Synapse bug? where the m.room.create event appears twice in sync response
// when first syncing the room
@ -38,10 +36,10 @@ function deduplicateEvents(events) {
}
export class SyncWriter {
constructor({roomId, fragmentIdComparer}) {
constructor({roomId, fragmentIdComparer, memberWriter, relationWriter}) {
this._roomId = roomId;
this._memberWriter = new MemberWriter(roomId);
this._relationWriter = new RelationWriter(roomId, fragmentIdComparer);
this._memberWriter = memberWriter;
this._relationWriter = relationWriter;
this._fragmentIdComparer = fragmentIdComparer;
this._lastLiveKey = null;
}
@ -174,9 +172,9 @@ export class SyncWriter {
txn.timelineEvents.insert(storageEntry);
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
entries.push(entry);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log);
if (updatedRelationTargetEntry) {
updatedEntries.push(updatedRelationTargetEntry);
const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);
if (updatedRelationTargetEntries) {
updatedEntries.push(...updatedRelationTargetEntries);
}
// update state events after writing event, so for a member event,
// we only update the member info after having written the member event

View file

@ -0,0 +1,51 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {REDACTION_TYPE} from "../common.js";
export const REACTION_TYPE = "m.reaction";
export const ANNOTATION_RELATION_TYPE = "m.annotation";
export function createAnnotation(targetId, key) {
return {
"m.relates_to": {
"event_id": targetId,
key,
"rel_type": ANNOTATION_RELATION_TYPE
}
};
}
export function getRelatedEventId(event) {
if (event.type === REDACTION_TYPE) {
return event.redacts;
} else {
const relation = getRelation(event);
if (relation) {
return relation.event_id;
}
}
return null;
}
export function getRelationFromContent(content) {
return content?.["m.relates_to"];
}
export function getRelation(event) {
return getRelationFromContent(event.content);
}

View file

@ -22,6 +22,7 @@ export const STORE_NAMES = Object.freeze([
"invites",
"roomMembers",
"timelineEvents",
"timelineRelations",
"timelineFragments",
"pendingEvents",
"userIdentities",

View file

@ -21,6 +21,7 @@ import {SessionStore} from "./stores/SessionStore.js";
import {RoomSummaryStore} from "./stores/RoomSummaryStore.js";
import {InviteStore} from "./stores/InviteStore.js";
import {TimelineEventStore} from "./stores/TimelineEventStore.js";
import {TimelineRelationStore} from "./stores/TimelineRelationStore.js";
import {RoomStateStore} from "./stores/RoomStateStore.js";
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
@ -82,6 +83,10 @@ export class Transaction {
return this._store("timelineEvents", idbStore => new TimelineEventStore(idbStore));
}
get timelineRelations() {
return this._store("timelineRelations", idbStore => new TimelineRelationStore(idbStore));
}
get roomState() {
return this._store("roomState", idbStore => new RoomStateStore(idbStore));
}

View file

@ -16,6 +16,7 @@ export const schema = [
createInviteStore,
createArchivedRoomSummaryStore,
migrateOperationScopeIndex,
createTimelineRelationsStore,
];
// TODO: how to deal with git merge conflicts of this array?
@ -136,3 +137,8 @@ async function migrateOperationScopeIndex(db, txn) {
console.error("could not migrate operations", err.stack);
}
}
//v10
function createTimelineRelationsStore(db) {
db.createObjectStore("timelineRelations", {keyPath: "key"});
}

View file

@ -0,0 +1,75 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {MIN_UNICODE, MAX_UNICODE} from "./common.js";
function encodeKey(roomId, targetEventId, relType, sourceEventId) {
return `${roomId}|${targetEventId}|${relType}|${sourceEventId}`;
}
function decodeKey(key) {
const [roomId, targetEventId, relType, sourceEventId] = key.split("|");
return {roomId, targetEventId, relType, sourceEventId};
}
export class TimelineRelationStore {
constructor(store) {
this._store = store;
}
add(roomId, targetEventId, relType, sourceEventId) {
return this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
}
remove(roomId, targetEventId, relType, sourceEventId) {
return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId));
}
removeAllForTarget(roomId, targetId) {
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
true,
true
);
return this._store.delete(range);
}
async getForTargetAndType(roomId, targetId, relType) {
// exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, relType, MIN_UNICODE),
encodeKey(roomId, targetId, relType, MAX_UNICODE),
true,
true
);
const items = await this._store.selectAll(range);
return items.map(i => decodeKey(i.key));
}
async getAllForTarget(roomId, targetId) {
// exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
true,
true
);
const items = await this._store.selectAll(range);
return items.map(i => decodeKey(i.key));
}
}

61
src/mocks/ListObserver.js Normal file
View file

@ -0,0 +1,61 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export class ListObserver {
constructor() {
this._queue = [];
this._backlog = [];
}
next() {
if (this._backlog.length) {
return Promise.resolve(this._backlog.shift());
} else {
return new Promise(resolve => {
this._queue.push(resolve);
});
}
}
_fullfillNext(value) {
if (this._queue.length) {
const resolve = this._queue.shift();
resolve(value);
} else {
this._backlog.push(value);
}
}
onReset() {
this._fullfillNext({type: "reset"});
}
onAdd(index, value) {
this._fullfillNext({type: "add", index, value});
}
onUpdate(index, value, params) {
this._fullfillNext({type: "update", index, value, params});
}
onRemove(index, value) {
this._fullfillNext({type: "remove", index, value});
}
onMove(fromIdx, toIdx, value) {
this._fullfillNext({type: "move", fromIdx, toIdx, value});
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export function createEvent(type, id = null) {
return {type, event_id: id};
export function createEvent(type, id = null, sender = null) {
return {type, event_id: id, sender};
}
export function withContent(content, event) {
@ -33,3 +33,7 @@ export function withTextBody(body, event) {
export function withTxnId(txnId, event) {
return Object.assign({}, event, {unsigned: {transaction_id: txnId}});
}
export function withRedacts(redacts, reason, event) {
return Object.assign({redacts, content: {reason}}, event);
}

View file

@ -23,6 +23,7 @@ import {BaseObservableMap} from "./map/BaseObservableMap.js";
export { ObservableArray } from "./list/ObservableArray.js";
export { SortedArray } from "./list/SortedArray.js";
export { MappedList } from "./list/MappedList.js";
export { AsyncMappedList } from "./list/AsyncMappedList.js";
export { ConcatList } from "./list/ConcatList.js";
export { ObservableMap } from "./map/ObservableMap.js";

View file

@ -0,0 +1,197 @@
/*
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.
*/
import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class AsyncMappedList extends BaseMappedList {
constructor(sourceList, mapper, updater, removeCallback) {
super(sourceList, mapper, updater, removeCallback);
this._eventQueue = null;
}
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._eventQueue = [];
this._mappedValues = [];
let idx = 0;
for (const item of this._sourceList) {
this._eventQueue.push(new AddEvent(idx, item));
idx += 1;
}
this._flush();
}
async _flush() {
if (this._flushing) {
return;
}
this._flushing = true;
try {
while (this._eventQueue.length) {
const event = this._eventQueue.shift();
await event.run(this);
}
} finally {
this._flushing = false;
}
}
onReset() {
if (this._eventQueue) {
this._eventQueue.push(new ResetEvent());
this._flush();
}
}
onAdd(index, value) {
if (this._eventQueue) {
this._eventQueue.push(new AddEvent(index, value));
this._flush();
}
}
onUpdate(index, value, params) {
if (this._eventQueue) {
this._eventQueue.push(new UpdateEvent(index, value, params));
this._flush();
}
}
onRemove(index) {
if (this._eventQueue) {
this._eventQueue.push(new RemoveEvent(index));
this._flush();
}
}
onMove(fromIdx, toIdx) {
if (this._eventQueue) {
this._eventQueue.push(new MoveEvent(fromIdx, toIdx));
this._flush();
}
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
this._eventQueue = null;
this._mappedValues = null;
}
}
class AddEvent {
constructor(index, value) {
this.index = index;
this.value = value;
}
async run(list) {
const mappedValue = await list._mapper(this.value);
runAdd(list, this.index, mappedValue);
}
}
class UpdateEvent {
constructor(index, value, params) {
this.index = index;
this.value = value;
this.params = params;
}
async run(list) {
runUpdate(list, this.index, this.value, this.params);
}
}
class RemoveEvent {
constructor(index) {
this.index = index;
}
async run(list) {
runRemove(list, this.index);
}
}
class MoveEvent {
constructor(fromIdx, toIdx) {
this.fromIdx = fromIdx;
this.toIdx = toIdx;
}
async run(list) {
runMove(list, this.fromIdx, this.toIdx);
}
}
class ResetEvent {
async run(list) {
runReset(list);
}
}
import {ObservableArray} from "./ObservableArray.js";
import {ListObserver} from "../../mocks/ListObserver.js";
export function tests() {
return {
"events are emitted in order": async assert => {
const double = n => n * n;
const source = new ObservableArray();
const mapper = new AsyncMappedList(source, async n => {
await new Promise(r => setTimeout(r, n));
return {n: double(n)};
}, (o, params, n) => {
o.n = double(n);
});
const observer = new ListObserver();
mapper.subscribe(observer);
source.append(2); // will sleep this amount, so second append would take less time
source.append(1);
source.update(0, 7, "lucky seven")
source.remove(0);
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 1);
assert.equal(type, "add");
assert.equal(index, 0);
assert.equal(value.n, 4);
}
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 2);
assert.equal(type, "add");
assert.equal(index, 1);
assert.equal(value.n, 1);
}
{
const {type, index, value, params} = await observer.next();
assert.equal(mapper.length, 2);
assert.equal(type, "update");
assert.equal(index, 0);
assert.equal(value.n, 49);
assert.equal(params, "lucky seven");
}
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 1);
assert.equal(type, "remove");
assert.equal(index, 0);
assert.equal(value.n, 49);
}
}
}
}

View file

@ -0,0 +1,77 @@
/*
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.
*/
import {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class BaseMappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._mappedValues = null;
this._sourceUnsubscribe = null;
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
export function runAdd(list, index, mappedValue) {
list._mappedValues.splice(index, 0, mappedValue);
list.emitAdd(index, mappedValue);
}
export function runUpdate(list, index, value, params) {
const mappedValue = list._mappedValues[index];
if (list._updater) {
list._updater(mappedValue, params, value);
}
list.emitUpdate(index, mappedValue, params);
}
export function runRemove(list, index) {
const mappedValue = list._mappedValues[index];
list._mappedValues.splice(index, 1);
if (list._removeCallback) {
list._removeCallback(mappedValue);
}
list.emitRemove(index, mappedValue);
}
export function runMove(list, fromIdx, toIdx) {
const mappedValue = list._mappedValues[fromIdx];
list._mappedValues.splice(fromIdx, 1);
list._mappedValues.splice(toIdx, 0, mappedValue);
list.emitMove(fromIdx, toIdx, mappedValue);
}
export function runReset(list) {
list._mappedValues = [];
list.emitReset();
}

View file

@ -15,20 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class MappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._sourceUnsubscribe = null;
this._mappedValues = null;
}
import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class MappedList extends BaseMappedList {
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._mappedValues = [];
@ -38,14 +27,12 @@ export class MappedList extends BaseObservableList {
}
onReset() {
this._mappedValues = [];
this.emitReset();
runReset(this);
}
onAdd(index, value) {
const mappedValue = this._mapper(value);
this._mappedValues.splice(index, 0, mappedValue);
this.emitAdd(index, mappedValue);
runAdd(this, index, mappedValue);
}
onUpdate(index, value, params) {
@ -53,47 +40,24 @@ export class MappedList extends BaseObservableList {
if (!this._mappedValues) {
return;
}
const mappedValue = this._mappedValues[index];
if (this._updater) {
this._updater(mappedValue, params, value);
}
this.emitUpdate(index, mappedValue, params);
runUpdate(this, index, value, params);
}
onRemove(index) {
const mappedValue = this._mappedValues[index];
this._mappedValues.splice(index, 1);
if (this._removeCallback) {
this._removeCallback(mappedValue);
}
this.emitRemove(index, mappedValue);
runRemove(this, index);
}
onMove(fromIdx, toIdx) {
const mappedValue = this._mappedValues[fromIdx];
this._mappedValues.splice(fromIdx, 1);
this._mappedValues.splice(toIdx, 0, mappedValue);
this.emitMove(fromIdx, toIdx, mappedValue);
runMove(this, fromIdx, toIdx);
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
import {ObservableArray} from "./ObservableArray.js";
import {BaseObservableList} from "./BaseObservableList.js";
export async function tests() {
class MockList extends BaseObservableList {

View file

@ -44,6 +44,13 @@ export class ObservableArray extends BaseObservableList {
this.emitAdd(idx, item);
}
update(idx, item, params = null) {
if (idx < this._items.length) {
this._items[idx] = item;
this.emitUpdate(idx, item, params);
}
}
get array() {
return this._items;
}

View file

@ -46,6 +46,16 @@ export class SortedArray extends BaseObservableList {
return findAndUpdateInArray(predicate, this._items, this, updater);
}
getAndUpdate(item, updater, updateParams = null) {
const idx = this.indexOf(item);
if (idx !== -1) {
const existingItem = this._items[idx];
const newItem = updater(existingItem, item);
this._items[idx] = newItem;
this.emitUpdate(idx, newItem, updateParams);
}
}
update(item, updateParams = null) {
const idx = this.indexOf(item);
if (idx !== -1) {

View file

@ -74,6 +74,10 @@ export class ObservableMap extends BaseObservableMap {
values() {
return this._values.values();
}
keys() {
return this._values.keys();
}
}
export function tests() {

View file

@ -6,6 +6,8 @@
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Twemoji'),
local('Twemoji Mozilla'),
local('Android Emoji'),
local('EmojiSymbols'),
local('Symbola');

View file

@ -666,7 +666,7 @@ button.link {
margin-bottom: 10px;
}
.menu .menu-item{
.menu button {
border-radius: 4px;
border: none;
background-color: transparent;
@ -681,6 +681,16 @@ button.link {
color: #FF4B55;
}
.menu .quick-reactions {
display: flex;
padding: 8px 32px 8px 8px;
}
.menu .quick-reactions button {
padding: 2px 4px;
text-align: center;
}
.InviteView_body {
display: flex;
justify-content: space-around;

View file

@ -20,7 +20,8 @@ limitations under the License.
grid-template:
"avatar sender" auto
"avatar body" auto
"time body" 1fr /
"time body" 1fr
"time reactions" auto /
30px 1fr;
column-gap: 8px;
padding: 4px;
@ -37,9 +38,10 @@ limitations under the License.
@media screen and (max-width: 800px) {
.Timeline_message {
grid-template:
"avatar sender" auto
"body body" 1fr
"time time" auto /
"avatar sender" auto
"body body" 1fr
"time time" auto
"reactions reactions" auto /
30px 1fr;
}
@ -57,6 +59,7 @@ limitations under the License.
.Timeline_message:hover > .Timeline_messageOptions,
.Timeline_message.menuOpen > .Timeline_messageOptions {
display: block;
user-select: none;
}
.Timeline_messageAvatar {
@ -104,6 +107,7 @@ limitations under the License.
.Timeline_messageBody time {
padding: 2px 0 0px 10px;
user-select: none;
}
.Timeline_messageBody time, .Timeline_messageTime {
@ -133,6 +137,9 @@ limitations under the License.
.hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); }
.hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); }
.Timeline_messageBody a {
word-break: break-all;
}
.Timeline_messageBody .media {
display: grid;
@ -211,6 +218,42 @@ only loads when the top comes into view*/
color: #ff4b55;
}
.Timeline_messageReactions {
grid-area: reactions;
margin-top: 6px;
}
.Timeline_messageReactions button {
display: inline-flex;
line-height: 2.0rem;
margin-right: 6px;
padding: 1px 6px;
border: 1px solid #e9edf1;
border-radius: 10px;
background-color: #f3f8fd;
cursor: pointer;
user-select: none;
vertical-align: middle;
}
.Timeline_messageReactions button.active {
background-color: #e9fff9;
border-color: #0DBD8B;
}
@keyframes glow-reaction-border {
0% { border-color: #e9edf1; }
100% { border-color: #0DBD8B; }
}
.Timeline_messageReactions button.active.pending {
animation-name: glow-reaction-border;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.AnnouncementView {
margin: 5px 0;
padding: 5px 10%;
@ -227,7 +270,3 @@ only loads when the top comes into view*/
.GapView > :not(:first-child) {
margin-left: 12px;
}
.Timeline_messageBody a {
word-break: break-all;
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag} from "./html.js";
import {errorToDOM} from "./error.js";
import {el} from "./html.js";
import {mountView} from "./utils.js";
function insertAt(parentNode, idx, childNode) {
const isLast = idx === parentNode.childElementCount;
@ -28,10 +28,11 @@ function insertAt(parentNode, idx, childNode) {
}
export class ListView {
constructor({list, onItemClick, className, parentProvidesUpdates = true}, childCreator) {
constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) {
this._onItemClick = onItemClick;
this._list = list;
this._className = className;
this._tagName = tagName;
this._root = null;
this._subscription = null;
this._childCreator = childCreator;
@ -62,7 +63,7 @@ export class ListView {
if (this._className) {
attr.className = this._className;
}
this._root = tag.ul(attr);
this._root = el(this._tagName, attr);
this.loadList();
if (this._onItemClick) {
this._root.addEventListener("click", this._onClick);
@ -107,12 +108,7 @@ export class ListView {
for (let item of this._list) {
const child = this._childCreator(item);
this._childInstances.push(child);
try {
const childDomNode = child.mount(this._mountArgs);
fragment.appendChild(childDomNode);
} catch (err) {
fragment.appendChild(errorToDOM(err));
}
fragment.appendChild(mountView(child, this._mountArgs));
}
this._root.appendChild(fragment);
}
@ -121,7 +117,7 @@ export class ListView {
this.onBeforeListChanged();
const child = this._childCreator(value);
this._childInstances.splice(idx, 0, child);
insertAt(this._root, idx, child.mount(this._mountArgs));
insertAt(this._root, idx, mountView(child, this._mountArgs));
this.onListChanged();
}

View file

@ -27,18 +27,7 @@ export class Menu extends TemplateView {
}
render(t) {
return t.ul({className: "menu", role: "menu"}, this._options.map(o => {
const className = {
destructive: o.destructive,
};
if (o.icon) {
className.icon = true;
className[o.icon] = true;
}
return t.li({
className,
}, t.button({className:"menu-item", onClick: o.callback}, o.label));
}));
return t.ul({className: "menu", role: "menu"}, this._options.map(o => o.toDOM(t)));
}
}
@ -59,4 +48,17 @@ class MenuOption {
this.destructive = true;
return this;
}
toDOM(t) {
const className = {
destructive: this.destructive,
};
if (this.icon) {
className.icon = true;
className[this.icon] = true;
}
return t.li({
className,
}, t.button({className:"menu-item", onClick: this.callback}, this.label));
}
}

View file

@ -1,94 +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 {errorToDOM} from "./error.js";
export class SwitchView {
constructor(defaultView) {
this._childView = defaultView;
}
mount() {
return this._childView.mount();
}
unmount() {
return this._childView.unmount();
}
root() {
return this._childView.root();
}
update() {
return this._childView.update();
}
switch(newView) {
const oldRoot = this.root();
this._childView.unmount();
this._childView = newView;
let newRoot;
try {
newRoot = this._childView.mount();
} catch (err) {
newRoot = errorToDOM(err);
}
const parent = oldRoot.parentNode;
if (parent) {
parent.replaceChild(newRoot, oldRoot);
}
}
get childView() {
return this._childView;
}
}
/*
// SessionLoadView
// should this be the new switch view?
// and the other one be the BasicSwitchView?
new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => {
if (loading) {
return new InlineTemplateView(vm, t => {
return t.div({className: "loading"}, [
t.span({className: "spinner"}),
t.span(vm => vm.loadingText)
]);
});
} else {
return new SessionView(vm.sessionViewModel);
}
});
*/
export class BoundSwitchView extends SwitchView {
constructor(value, mapper, viewCreator) {
super(viewCreator(mapper(value), value));
this._mapper = mapper;
this._viewCreator = viewCreator;
this._mappedValue = mapper(value);
}
update(value) {
const mappedValue = this._mapper(value);
if (mappedValue !== this._mappedValue) {
this._mappedValue = mappedValue;
this.switch(this._viewCreator(this._mappedValue, value));
} else {
super.update(value);
}
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js";
import {errorToDOM} from "./error.js";
import {mountView} from "./utils.js";
import {BaseUpdateView} from "./BaseUpdateView.js";
function objHasFns(obj) {
@ -282,17 +282,11 @@ class TemplateBuilder {
return node;
}
// this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template
// this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template
// you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree).
view(view, mountOptions = undefined) {
let root;
try {
root = view.mount(mountOptions);
} catch (err) {
return errorToDOM(err);
}
this._templateView.addSubView(view);
return root;
return mountView(view, mountOptions);
}
// map a value to a view, every time the value changes

View file

@ -0,0 +1,27 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {errorToDOM} from "./error.js";
export function mountView(view, mountArgs = undefined) {
let node;
try {
node = view.mount(mountArgs);
} catch (err) {
node = errorToDOM(err);
}
return node;
}

View file

@ -74,6 +74,7 @@ export class TimelineList extends ListView {
}
}
catch (err) {
console.error(err);
//ignore error, as it is handled in the VM
}
finally {

View file

@ -17,9 +17,11 @@ limitations under the License.
import {renderStaticAvatar} from "../../../avatar.js";
import {tag} from "../../../general/html.js";
import {mountView} from "../../../general/utils.js";
import {TemplateView} from "../../../general/TemplateView.js";
import {Popup} from "../../../general/Popup.js";
import {Menu} from "../../../general/Menu.js";
import {ReactionsView} from "./ReactionsView.js";
export class BaseMessageView extends TemplateView {
constructor(value) {
@ -35,6 +37,7 @@ export class BaseMessageView extends TemplateView {
unverified: vm.isUnverified,
continuation: vm => vm.isContinuation,
}}, [
// dynamically added and removed nodes are handled below
this.renderMessageBody(t, vm),
// should be after body as it is overlayed on top
t.button({className: "Timeline_messageOptions"}, "⋯"),
@ -53,6 +56,21 @@ export class BaseMessageView extends TemplateView {
li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild);
}
});
// similarly, we could do this with a simple ifView,
// but that adds a comment node to all messages without reactions
let reactionsView = null;
t.mapSideEffect(vm => vm.reactions, reactions => {
if (reactions && !reactionsView) {
reactionsView = new ReactionsView(vm.reactions);
this.addSubView(reactionsView);
li.appendChild(mountView(reactionsView));
} else if (!reactions && reactionsView) {
li.removeChild(reactionsView.root());
reactionsView.unmount();
this.removeSubView(reactionsView);
reactionsView = null;
}
});
return li;
}
@ -92,6 +110,9 @@ export class BaseMessageView extends TemplateView {
createMenuOptions(vm) {
const options = [];
if (vm.canReact && vm.shape !== "redacted") {
options.push(new QuickReactionsMenuOption(vm));
}
if (vm.canAbortSending) {
options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending()));
} else if (vm.canRedact) {
@ -102,3 +123,21 @@ export class BaseMessageView extends TemplateView {
renderMessageBody() {}
}
class QuickReactionsMenuOption {
constructor(vm) {
this._vm = vm;
}
toDOM(t) {
const emojiButtons = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
return t.button({onClick: () => this._vm.react(emoji)}, emoji);
});
const customButton = t.button({onClick: () => {
const key = prompt("Enter your reaction (emoji)");
if (key) {
this._vm.react(key);
}
}}, "…");
return t.li({className: "quick-reactions"}, [...emojiButtons, customButton]);
}
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ListView} from "../../../general/ListView.js";
import {TemplateView} from "../../../general/TemplateView.js";
export class ReactionsView extends ListView {
constructor(reactionsViewModel) {
const options = {
className: "Timeline_messageReactions",
tagName: "div",
list: reactionsViewModel.reactions,
onItemClick: reactionView => reactionView.onClick(),
}
super(options, reactionVM => new ReactionView(reactionVM));
}
}
class ReactionView extends TemplateView {
render(t, vm) {
return t.button({
className: {
active: vm => vm.isActive,
pending: vm => vm.isPending
},
}, [vm.key, " ", vm => `${vm.count}`]);
}
onClick() {
this.value.toggle();
}
}