388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
/*
|
|
Copyright 2023 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 { MAIN_ROOM_TIMELINE } from "../@types/read_receipts.js";
|
|
import { threadIdForReceipt } from "../client.js";
|
|
import { RoomEvent } from "./room.js";
|
|
import { logger } from "../logger.js";
|
|
|
|
/**
|
|
* The latest receipts we have for a room.
|
|
*/
|
|
export class RoomReceipts {
|
|
constructor(room) {
|
|
_defineProperty(this, "room", void 0);
|
|
_defineProperty(this, "threadedReceipts", void 0);
|
|
_defineProperty(this, "unthreadedReceipts", void 0);
|
|
_defineProperty(this, "danglingReceipts", void 0);
|
|
/**
|
|
* Look for dangling receipts for the given event ID,
|
|
* and add them to the thread of unthread receipts if found.
|
|
* @param event - the event to look for
|
|
*/
|
|
_defineProperty(this, "onTimelineEvent", event => {
|
|
const eventId = event.getId();
|
|
if (!eventId) return;
|
|
const danglingReceipts = this.danglingReceipts.remove(eventId);
|
|
danglingReceipts?.forEach(danglingReceipt => {
|
|
// The receipt is a thread receipt
|
|
if (danglingReceipt.receipt.thread_id) {
|
|
this.threadedReceipts.set(danglingReceipt.receipt.thread_id, danglingReceipt.eventId, danglingReceipt.receiptType, danglingReceipt.userId, danglingReceipt.receipt.ts, danglingReceipt.synthetic);
|
|
} else {
|
|
this.unthreadedReceipts.set(eventId, danglingReceipt.receiptType, danglingReceipt.userId, danglingReceipt.receipt.ts, danglingReceipt.synthetic);
|
|
}
|
|
});
|
|
});
|
|
this.room = room;
|
|
this.threadedReceipts = new ThreadedReceipts(room);
|
|
this.unthreadedReceipts = new ReceiptsByUser(room);
|
|
this.danglingReceipts = new DanglingReceipts();
|
|
// We listen for timeline events so we can process dangling receipts
|
|
room.on(RoomEvent.Timeline, this.onTimelineEvent);
|
|
}
|
|
|
|
/**
|
|
* Remember the receipt information supplied. For each receipt:
|
|
*
|
|
* If we don't have the event for this receipt, store it as "dangling" so we
|
|
* can process it later.
|
|
*
|
|
* Otherwise store it per-user in either the threaded store for its
|
|
* thread_id, or the unthreaded store if there is no thread_id.
|
|
*
|
|
* Ignores any receipt that is before an existing receipt for the same user
|
|
* (in the same thread, if applicable). "Before" is defined by the
|
|
* unfilteredTimelineSet of the room.
|
|
*/
|
|
add(receiptContent, synthetic) {
|
|
/*
|
|
Transform this structure:
|
|
{
|
|
"$EVENTID": {
|
|
"m.read|m.read.private": {
|
|
"@user:example.org": {
|
|
"ts": 1661,
|
|
"thread_id": "main|$THREAD_ROOT_ID" // or missing/undefined for an unthreaded receipt
|
|
}
|
|
}
|
|
},
|
|
...
|
|
}
|
|
into maps of:
|
|
threaded :: threadid :: userId :: ReceiptInfo
|
|
unthreaded :: userId :: ReceiptInfo
|
|
dangling :: eventId :: DanglingReceipt
|
|
*/
|
|
for (const [eventId, eventReceipt] of Object.entries(receiptContent)) {
|
|
for (const [receiptType, receiptsByUser] of Object.entries(eventReceipt)) {
|
|
for (const [userId, receipt] of Object.entries(receiptsByUser)) {
|
|
const referencedEvent = this.room.findEventById(eventId);
|
|
if (!referencedEvent) {
|
|
this.danglingReceipts.add(new DanglingReceipt(eventId, receiptType, userId, receipt, synthetic));
|
|
} else if (receipt.thread_id) {
|
|
this.threadedReceipts.set(receipt.thread_id, eventId, receiptType, userId, receipt.ts, synthetic);
|
|
} else {
|
|
this.unthreadedReceipts.set(eventId, receiptType, userId, receipt.ts, synthetic);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
hasUserReadEvent(userId, eventId) {
|
|
const unthreaded = this.unthreadedReceipts.get(userId);
|
|
if (unthreaded) {
|
|
if (isAfterOrSame(unthreaded.eventId, eventId, this.room)) {
|
|
// The unthreaded receipt is after this event, so we have read it.
|
|
return true;
|
|
}
|
|
}
|
|
const event = this.room.findEventById(eventId);
|
|
if (!event) {
|
|
// We don't know whether the user has read it - default to caution and say no.
|
|
// This shouldn't really happen and feels like it ought to be an exception: let's
|
|
// log a warn for now.
|
|
logger.warn(`hasUserReadEvent event ID ${eventId} not found in room ${this.room.roomId}: this shouldn't happen!`);
|
|
return false;
|
|
}
|
|
const threadId = threadIdForReceipt(event);
|
|
const threaded = this.threadedReceipts.get(threadId, userId);
|
|
if (threaded) {
|
|
if (isAfterOrSame(threaded.eventId, eventId, this.room)) {
|
|
// The threaded receipt is after this event, so we have read it.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// TODO: what if they sent the second-last event in the thread?
|
|
if (this.userSentLatestEventInThread(threadId, userId)) {
|
|
// The user sent the latest message in this event's thread, so we
|
|
// consider everything in the thread to be read.
|
|
//
|
|
// Note: maybe we don't need this because synthetic receipts should
|
|
// do this job for us?
|
|
return true;
|
|
}
|
|
|
|
// Neither of the receipts were after the event, so it's unread.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @returns true if the thread with this ID can be found, and the supplied
|
|
* user sent the latest message in it.
|
|
*/
|
|
userSentLatestEventInThread(threadId, userId) {
|
|
const timeline = threadId === MAIN_ROOM_TIMELINE ? this.room.getLiveTimeline().getEvents() : this.room.getThread(threadId)?.timeline;
|
|
return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId);
|
|
}
|
|
}
|
|
|
|
// --- implementation details ---
|
|
|
|
/**
|
|
* The information "inside" a receipt once it has been stored inside
|
|
* RoomReceipts - what eventId it refers to, its type, and its ts.
|
|
*
|
|
* Does not contain userId or threadId since these are stored as keys of the
|
|
* maps in RoomReceipts.
|
|
*/
|
|
class ReceiptInfo {
|
|
constructor(eventId, receiptType, ts) {
|
|
this.eventId = eventId;
|
|
this.receiptType = receiptType;
|
|
this.ts = ts;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Everything we know about a receipt that is "dangling" because we can't find
|
|
* the event to which it refers.
|
|
*/
|
|
class DanglingReceipt {
|
|
constructor(eventId, receiptType, userId, receipt, synthetic) {
|
|
this.eventId = eventId;
|
|
this.receiptType = receiptType;
|
|
this.userId = userId;
|
|
this.receipt = receipt;
|
|
this.synthetic = synthetic;
|
|
}
|
|
}
|
|
class UserReceipts {
|
|
constructor(room) {
|
|
_defineProperty(this, "room", void 0);
|
|
/**
|
|
* The real receipt for this user.
|
|
*/
|
|
_defineProperty(this, "real", void 0);
|
|
/**
|
|
* The synthetic receipt for this user. If this is defined, it is later than real.
|
|
*/
|
|
_defineProperty(this, "synthetic", void 0);
|
|
this.room = room;
|
|
this.real = undefined;
|
|
this.synthetic = undefined;
|
|
}
|
|
set(synthetic, receiptInfo) {
|
|
if (synthetic) {
|
|
this.synthetic = receiptInfo;
|
|
} else {
|
|
this.real = receiptInfo;
|
|
}
|
|
|
|
// Preserve the invariant: synthetic is only defined if it's later than real
|
|
if (this.synthetic && this.real) {
|
|
if (isAfterOrSame(this.real.eventId, this.synthetic.eventId, this.room)) {
|
|
this.synthetic = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the latest receipt we have - synthetic if we have one (and it's
|
|
* later), otherwise real.
|
|
*/
|
|
get() {
|
|
// Relies on the invariant that synthetic is only defined if it's later than real.
|
|
return this.synthetic ?? this.real;
|
|
}
|
|
|
|
/**
|
|
* Return the latest receipt we have of the specified type (synthetic or not).
|
|
*/
|
|
getByType(synthetic) {
|
|
return synthetic ? this.synthetic : this.real;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The latest receipt info we have, either for a single thread, or all the
|
|
* unthreaded receipts for a room.
|
|
*
|
|
* userId: ReceiptInfo
|
|
*/
|
|
class ReceiptsByUser {
|
|
constructor(room) {
|
|
_defineProperty(this, "room", void 0);
|
|
/** map of userId: UserReceipts */
|
|
_defineProperty(this, "data", void 0);
|
|
this.room = room;
|
|
this.data = new Map();
|
|
}
|
|
|
|
/**
|
|
* Add the supplied receipt to our structure, if it is not earlier than the
|
|
* one we already hold for this user.
|
|
*/
|
|
set(eventId, receiptType, userId, ts, synthetic) {
|
|
const userReceipts = getOrCreate(this.data, userId, () => new UserReceipts(this.room));
|
|
const existingReceipt = userReceipts.getByType(synthetic);
|
|
if (existingReceipt && isAfter(existingReceipt.eventId, eventId, this.room)) {
|
|
// The new receipt is before the existing one - don't store it.
|
|
return;
|
|
}
|
|
|
|
// Possibilities:
|
|
//
|
|
// 1. there was no existing receipt, or
|
|
// 2. the existing receipt was before this one, or
|
|
// 3. we were unable to compare the receipts.
|
|
//
|
|
// In the case of 3 it's difficult to decide what to do, so the
|
|
// most-recently-received receipt wins.
|
|
//
|
|
// Case 3 can only happen if the events for these receipts have
|
|
// disappeared, which is quite unlikely since the new one has just been
|
|
// checked, and the old one was checked before it was inserted here.
|
|
//
|
|
// We go ahead and store this receipt (replacing the other if it exists)
|
|
userReceipts.set(synthetic, new ReceiptInfo(eventId, receiptType, ts));
|
|
}
|
|
|
|
/**
|
|
* Find the latest receipt we have for this user. (Note - there is only one
|
|
* receipt per user, because we are already inside a specific thread or
|
|
* unthreaded list.)
|
|
*
|
|
* If there is a later synthetic receipt for this user, return that.
|
|
* Otherwise, return the real receipt.
|
|
*
|
|
* @returns the found receipt info, or undefined if we have no receipt for this user.
|
|
*/
|
|
get(userId) {
|
|
return this.data.get(userId)?.get();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The latest threaded receipts we have for a room.
|
|
*/
|
|
class ThreadedReceipts {
|
|
constructor(room) {
|
|
_defineProperty(this, "room", void 0);
|
|
/** map of threadId: ReceiptsByUser */
|
|
_defineProperty(this, "data", void 0);
|
|
this.room = room;
|
|
this.data = new Map();
|
|
}
|
|
|
|
/**
|
|
* Add the supplied receipt to our structure, if it is not earlier than one
|
|
* we already hold for this user in this thread.
|
|
*/
|
|
set(threadId, eventId, receiptType, userId, ts, synthetic) {
|
|
const receiptsByUser = getOrCreate(this.data, threadId, () => new ReceiptsByUser(this.room));
|
|
receiptsByUser.set(eventId, receiptType, userId, ts, synthetic);
|
|
}
|
|
|
|
/**
|
|
* Find the latest threaded receipt for the supplied user in the supplied thread.
|
|
*
|
|
* @returns the found receipt info or undefined if we don't have one.
|
|
*/
|
|
get(threadId, userId) {
|
|
return this.data.get(threadId)?.get(userId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All the receipts that we have received but can't process because we can't
|
|
* find the event they refer to.
|
|
*
|
|
* We hold on to them so we can process them if their event arrives later.
|
|
*/
|
|
class DanglingReceipts {
|
|
constructor() {
|
|
/**
|
|
* eventId: DanglingReceipt[]
|
|
*/
|
|
_defineProperty(this, "data", new Map());
|
|
}
|
|
/**
|
|
* Remember the supplied dangling receipt.
|
|
*/
|
|
add(danglingReceipt) {
|
|
const danglingReceipts = getOrCreate(this.data, danglingReceipt.eventId, () => []);
|
|
danglingReceipts.push(danglingReceipt);
|
|
}
|
|
|
|
/**
|
|
* Remove and return the dangling receipts for the given event ID.
|
|
* @param eventId - the event ID to look for
|
|
* @returns the found dangling receipts, or undefined if we don't have one.
|
|
*/
|
|
remove(eventId) {
|
|
const danglingReceipts = this.data.get(eventId);
|
|
this.data.delete(eventId);
|
|
return danglingReceipts;
|
|
}
|
|
}
|
|
function getOrCreate(m, key, createFn) {
|
|
const found = m.get(key);
|
|
if (found) {
|
|
return found;
|
|
} else {
|
|
const created = createFn();
|
|
m.set(key, created);
|
|
return created;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is left after right (or the same)?
|
|
*
|
|
* Only returns true if both events can be found, and left is after or the same
|
|
* as right.
|
|
*
|
|
* @returns left \>= right
|
|
*/
|
|
function isAfterOrSame(leftEventId, rightEventId, room) {
|
|
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
|
|
return comparison !== null && comparison >= 0;
|
|
}
|
|
|
|
/**
|
|
* Is left strictly after right?
|
|
*
|
|
* Only returns true if both events can be found, and left is strictly after right.
|
|
*
|
|
* @returns left \> right
|
|
*/
|
|
function isAfter(leftEventId, rightEventId, room) {
|
|
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
|
|
return comparison !== null && comparison > 0;
|
|
}
|
|
//# sourceMappingURL=room-receipts.js.map
|