commit
dbaef5117a
47 changed files with 2383 additions and 334 deletions
|
@ -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.
|
||||
|
||||
|
|
363
src/domain/session/room/timeline/ReactionsViewModel.js
Normal file
363
src/domain/session/room/timeline/ReactionsViewModel.js
Normal 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;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -333,6 +333,7 @@ export class Sync {
|
|||
storeNames.roomState,
|
||||
storeNames.roomMembers,
|
||||
storeNames.timelineEvents,
|
||||
storeNames.timelineRelations,
|
||||
storeNames.timelineFragments,
|
||||
storeNames.pendingEvents,
|
||||
storeNames.userIdentities,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -21,3 +21,7 @@ export function getPrevContentFromStateEvent(event) {
|
|||
}
|
||||
|
||||
export const REDACTION_TYPE = "m.room.redaction";
|
||||
|
||||
export function isRedacted(event) {
|
||||
return !!event?.unsigned?.redacted_because;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
}
|
||||
}
|
76
src/matrix/room/timeline/PendingAnnotation.js
Normal file
76
src/matrix/room/timeline/PendingAnnotation.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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!");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -47,4 +47,6 @@ export class BaseEntry {
|
|||
asEventKey() {
|
||||
return new EventKey(this.fragmentId, this.entryIndex);
|
||||
}
|
||||
|
||||
updateFrom() {}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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("🚀"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
51
src/matrix/room/timeline/relations.js
Normal file
51
src/matrix/room/timeline/relations.js
Normal 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);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export const STORE_NAMES = Object.freeze([
|
|||
"invites",
|
||||
"roomMembers",
|
||||
"timelineEvents",
|
||||
"timelineRelations",
|
||||
"timelineFragments",
|
||||
"pendingEvents",
|
||||
"userIdentities",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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"});
|
||||
}
|
||||
|
|
75
src/matrix/storage/idb/stores/TimelineRelationStore.js
Normal file
75
src/matrix/storage/idb/stores/TimelineRelationStore.js
Normal 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
61
src/mocks/ListObserver.js
Normal 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});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
197
src/observable/list/AsyncMappedList.js
Normal file
197
src/observable/list/AsyncMappedList.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
77
src/observable/list/BaseMappedList.js
Normal file
77
src/observable/list/BaseMappedList.js
Normal 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();
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -74,6 +74,10 @@ export class ObservableMap extends BaseObservableMap {
|
|||
values() {
|
||||
return this._values.values();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this._values.keys();
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -660,6 +660,16 @@ button.link {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.menu .quick-reactions {
|
||||
display: flex;
|
||||
padding: 8px 32px 8px 8px;
|
||||
}
|
||||
|
||||
.menu .quick-reactions button {
|
||||
padding: 2px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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({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({onClick: this.callback}, this.label));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
27
src/platform/web/ui/general/utils.js
Normal file
27
src/platform/web/ui/general/utils.js
Normal 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;
|
||||
}
|
|
@ -74,6 +74,7 @@ export class TimelineList extends ListView {
|
|||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
//ignore error, as it is handled in the VM
|
||||
}
|
||||
finally {
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
45
src/platform/web/ui/session/room/timeline/ReactionsView.js
Normal file
45
src/platform/web/ui/session/room/timeline/ReactionsView.js
Normal 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();
|
||||
}
|
||||
}
|
Reference in a new issue