2021-06-03 22:43:13 +05:30
|
|
|
/*
|
|
|
|
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";
|
|
|
|
|
2021-06-04 00:30:25 +05:30
|
|
|
export class ReactionsViewModel {
|
2021-06-23 15:11:28 +05:30
|
|
|
constructor(parentTile) {
|
|
|
|
this._parentTile = parentTile;
|
2021-06-03 22:43:13 +05:30
|
|
|
this._map = new ObservableMap();
|
|
|
|
this._reactions = this._map.sortValues((a, b) => a._compare(b));
|
|
|
|
}
|
|
|
|
|
2021-06-15 22:36:41 +05:30
|
|
|
/** @package */
|
2021-06-04 19:04:44 +05:30
|
|
|
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 {
|
2021-06-23 15:11:28 +05:30
|
|
|
this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentTile));
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (pendingAnnotations) {
|
2021-06-24 15:56:38 +05:30
|
|
|
for (const [key, annotation] of pendingAnnotations.entries()) {
|
2021-06-03 22:43:13 +05:30
|
|
|
const reaction = this._map.get(key);
|
|
|
|
if (reaction) {
|
2021-06-24 15:56:38 +05:30
|
|
|
reaction._tryUpdatePending(annotation);
|
|
|
|
this._map.update(key);
|
2021-06-03 22:43:13 +05:30
|
|
|
} else {
|
2021-06-24 15:56:38 +05:30
|
|
|
this._map.add(key, new ReactionViewModel(key, null, annotation, this._parentTile));
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const existingKey of this._map.keys()) {
|
2021-06-04 19:04:44 +05:30
|
|
|
const hasPending = pendingAnnotations?.has(existingKey);
|
|
|
|
const hasRemote = annotations?.hasOwnProperty(existingKey);
|
|
|
|
if (!hasRemote && !hasPending) {
|
2021-06-03 22:43:13 +05:30
|
|
|
this._map.remove(existingKey);
|
2021-06-04 19:04:44 +05:30
|
|
|
} else if (!hasRemote) {
|
|
|
|
if (this._map.get(existingKey)._tryUpdate(null)) {
|
|
|
|
this._map.update(existingKey);
|
|
|
|
}
|
|
|
|
} else if (!hasPending) {
|
2021-06-16 13:53:22 +05:30
|
|
|
if (this._map.get(existingKey)._tryUpdatePending(null)) {
|
2021-06-04 19:04:44 +05:30
|
|
|
this._map.update(existingKey);
|
|
|
|
}
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get reactions() {
|
|
|
|
return this._reactions;
|
|
|
|
}
|
2021-06-23 15:11:28 +05:30
|
|
|
|
2021-06-23 19:08:12 +05:30
|
|
|
getReaction(key) {
|
2021-06-23 15:11:28 +05:30
|
|
|
return this._map.get(key);
|
|
|
|
}
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
|
2021-06-04 00:30:25 +05:30
|
|
|
class ReactionViewModel {
|
2021-06-24 15:56:38 +05:30
|
|
|
constructor(key, annotation, pending, parentTile) {
|
2021-06-03 22:43:13 +05:30
|
|
|
this._key = key;
|
|
|
|
this._annotation = annotation;
|
2021-06-24 15:56:38 +05:30
|
|
|
this._pending = pending;
|
2021-06-23 15:11:28 +05:30
|
|
|
this._parentTile = parentTile;
|
2021-06-08 20:26:17 +05:30
|
|
|
this._isToggling = false;
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
_tryUpdate(annotation) {
|
2021-06-04 19:04:44 +05:30
|
|
|
const oneSetAndOtherNot = !!this._annotation !== !!annotation;
|
|
|
|
const bothSet = this._annotation && annotation;
|
|
|
|
const areDifferent = bothSet && (
|
2021-06-03 22:43:13 +05:30
|
|
|
annotation.me !== this._annotation.me ||
|
|
|
|
annotation.count !== this._annotation.count ||
|
|
|
|
annotation.firstTimestamp !== this._annotation.firstTimestamp
|
2021-06-04 19:04:44 +05:30
|
|
|
);
|
|
|
|
if (oneSetAndOtherNot || areDifferent) {
|
2021-06-03 22:43:13 +05:30
|
|
|
this._annotation = annotation;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-06-24 15:56:38 +05:30
|
|
|
_tryUpdatePending(pending) {
|
|
|
|
if (!pending && !this._pending) {
|
|
|
|
return false;
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|
2021-06-24 15:56:38 +05:30
|
|
|
this._pending = pending;
|
|
|
|
return true;
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|
|
|
|
|
2021-06-03 22:43:13 +05:30
|
|
|
get key() {
|
|
|
|
return this._key;
|
|
|
|
}
|
|
|
|
|
|
|
|
get count() {
|
2021-06-24 15:56:38 +05:30
|
|
|
return (this._pending?.count || 0) + (this._annotation?.count || 0);
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
get isPending() {
|
2021-06-24 15:56:38 +05:30
|
|
|
return this._pending !== null;
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
|
2021-06-23 19:08:12 +05:30
|
|
|
/** @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() {
|
2021-06-04 19:04:44 +05:30
|
|
|
return this._annotation?.me || this.isPending;
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
|
2021-06-24 15:56:38 +05:30
|
|
|
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;
|
2021-06-23 19:08:12 +05:30
|
|
|
}
|
|
|
|
|
2021-06-03 22:43:13 +05:30
|
|
|
_compare(other) {
|
2021-06-17 13:11:10 +05:30
|
|
|
// the comparator is also used to test for equality by sortValues, if the comparison returns 0
|
2021-06-11 14:34:48 +05:30
|
|
|
// 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;
|
|
|
|
}
|
2021-06-04 19:04:44 +05:30
|
|
|
if (this.count !== other.count) {
|
|
|
|
return other.count - this.count;
|
2021-06-03 23:27:29 +05:30
|
|
|
} else {
|
2021-06-24 15:56:38 +05:30
|
|
|
const cmp = this.firstTimestamp - other.firstTimestamp;
|
|
|
|
if (cmp === 0) {
|
|
|
|
return this.key < other.key ? -1 : 1;
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|
2021-06-24 15:56:38 +05:30
|
|
|
return cmp;
|
2021-06-03 23:27:29 +05:30
|
|
|
}
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
|
|
|
|
2021-06-24 18:03:16 +05:30
|
|
|
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;
|
|
|
|
}
|
2021-06-23 15:11:28 +05:30
|
|
|
}
|
|
|
|
}
|
2021-06-23 15:13:14 +05:30
|
|
|
|
|
|
|
// 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";
|
2021-07-14 15:06:39 +05:30
|
|
|
import {ObservableValue} from "../../../../observable/ObservableValue.js";
|
2021-08-06 19:49:48 +05:30
|
|
|
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
|
2021-06-23 15:13:14 +05:30
|
|
|
|
|
|
|
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;
|
2021-06-03 23:27:16 +05:30
|
|
|
}
|
2021-06-23 15:13:14 +05:30
|
|
|
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 {
|
2021-06-23 19:08:12 +05:30
|
|
|
// these are more an integration test than unit tests,
|
|
|
|
// but fully test the local echo when toggling and
|
|
|
|
// the correct send queue modifications happen
|
2021-06-23 15:13:14 +05:30
|
|
|
"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});
|
2021-07-14 15:06:39 +05:30
|
|
|
const powerLevelsObservable = new ObservableValue(new PowerLevels({ ownUserId: alice, membership: "join" }));
|
2021-06-23 15:13:14 +05:30
|
|
|
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
|
2021-07-14 15:06:39 +05:30
|
|
|
clock: new MockClock(), pendingEvents: queue.pendingEvents, powerLevelsObservable});
|
2021-06-23 15:13:14 +05:30
|
|
|
// 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
|
2021-06-23 19:08:12 +05:30
|
|
|
const reactionVM = messageTile.reactions.getReaction("🐶");
|
2021-06-23 15:13:14 +05:30
|
|
|
// 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
|
2021-06-24 16:45:20 +05:30
|
|
|
await reactionVM.toggle();
|
2021-06-23 15:13:14 +05:30
|
|
|
{
|
|
|
|
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
|
2021-06-24 16:45:20 +05:30
|
|
|
await reactionVM.toggle();
|
2021-06-23 15:13:14 +05:30
|
|
|
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
|
2021-06-24 16:45:20 +05:30
|
|
|
await reactionVM.toggle();
|
2021-06-23 15:13:14 +05:30
|
|
|
{
|
|
|
|
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});
|
2021-07-14 15:06:39 +05:30
|
|
|
const powerLevelsObservable = new ObservableValue(new PowerLevels({ ownUserId: alice, membership: "join" }));
|
2021-06-23 15:13:14 +05:30
|
|
|
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
|
2021-07-14 15:06:39 +05:30
|
|
|
clock: new MockClock(), pendingEvents: queue.pendingEvents, powerLevelsObservable});
|
2021-06-23 15:13:14 +05:30
|
|
|
|
|
|
|
// 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
|
2021-06-23 19:08:12 +05:30
|
|
|
const reactionVM = messageTile.reactions.getReaction("🐶");
|
2021-06-23 15:13:14 +05:30
|
|
|
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;
|
2021-06-24 16:45:20 +05:30
|
|
|
await reactionVM.toggle();
|
2021-06-23 15:13:14 +05:30
|
|
|
{
|
|
|
|
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
|
2021-06-24 16:45:20 +05:30
|
|
|
await reactionVM.toggle();
|
2021-06-23 15:13:14 +05:30
|
|
|
{
|
|
|
|
assert.equal(reactionVM.count, 1);
|
|
|
|
const {index, type} = await queueObserver.next();
|
|
|
|
assert.equal("remove", type);
|
|
|
|
assert.equal(redactionIndex, index);
|
|
|
|
redactionIndex = index;
|
|
|
|
}
|
|
|
|
},
|
2021-06-03 22:43:13 +05:30
|
|
|
}
|
2021-06-04 19:04:44 +05:30
|
|
|
}
|