From d5116d126ff732bc629662002a1cd2450428dda6 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 18 Aug 2022 11:53:26 +0100 Subject: [PATCH 1/4] MSC3773 POC --- src/filter.ts | 1 + src/models/room.ts | 34 ++++++++++++++++++++++++++++++++-- src/sync-accumulator.ts | 23 +++++++++++++++++------ src/sync.ts | 29 +++++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/filter.ts b/src/filter.ts index 663ba1bb932..79d57c5d582 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -57,6 +57,7 @@ export interface IRoomEventFilter extends IFilterComponent { types?: Array; related_by_senders?: Array; related_by_rel_types?: string[]; + unread_thread_notifications?: boolean; // Unstable values "io.element.relation_senders"?: Array; diff --git a/src/models/room.ts b/src/models/room.ts index 5de6fa7e128..414e6481a5c 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -207,6 +207,8 @@ export type RoomEventHandlerMap = { [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; } & ThreadHandlerMap & MatrixEventHandlerMap; +type NotificationCount = Partial>; + export class Room extends TypedEventEmitter { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } @@ -216,7 +218,8 @@ export class Room extends TypedEventEmitter // a pre-cached list for this purpose. private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] } - private notificationCounts: Partial> = {}; + private notificationCounts: NotificationCount = {}; + private threadNotifications: Record = {}; private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -1177,6 +1180,22 @@ export class Room extends TypedEventEmitter return event; } + /** + * Get sum of threads and roon notification counts + * @param {String} type The type of notification count to get. default: 'total' + * @return {Number} The notification count. + */ + public getTotalUnreadNotificationCount(type = NotificationCountType.Total): number { + return (this.getUnreadNotificationCount(type) ?? 0) + + this.getTotalThreadsUnreadNotificationCount(type); + } + + public getTotalThreadsUnreadNotificationCount(type = NotificationCountType.Total): number { + return Object.keys(this.threadNotifications).reduce((total: number, threadId: string) => { + return total + (this.getThreadUnreadNotificationCount(threadId, type) ?? 0); + }, 0); + } + /** * Get one of the notification counts for this room * @param {String} type The type of notification count to get. default: 'total' @@ -1184,7 +1203,7 @@ export class Room extends TypedEventEmitter * for this type. */ public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { - return this.notificationCounts[type]; + return this.notificationCounts[type] ?? 0; } /** @@ -1196,6 +1215,17 @@ export class Room extends TypedEventEmitter this.notificationCounts[type] = count; } + public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number | undefined { + return this.threadNotifications[threadId]?.[type]; + } + + public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { + const notificationCount = this.threadNotifications[threadId]; + if (notificationCount) { + notificationCount[type] = count; + } + } + public setSummary(summary: IRoomSummary): void { const heroes = summary["m.heroes"]; const joinedCount = summary["m.joined_member_count"]; diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 08686c32d67..9d5243e3c41 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -76,6 +76,9 @@ export interface IJoinedRoom { ephemeral: IEphemeral; account_data: IAccountData; unread_notifications: IUnreadNotificationCounts; + unread_thread_notifications?: { + [threadId: string]: IUnreadNotificationCounts; + }; } export interface IStrippedState { @@ -154,6 +157,9 @@ interface IRoom { _summary: Partial; _accountData: { [eventType: string]: IMinimalEvent }; _unreadNotifications: Partial; + _unreadThreadNotifications?: { + [threadId: string]: Partial; + }; _readReceipts: { [userId: string]: { data: IMinimalEvent; @@ -358,12 +364,13 @@ export class SyncAccumulator { // Create truly empty objects so event types of 'hasOwnProperty' and co // don't cause this code to break. this.joinRooms[roomId] = { - _currentState: Object.create(null), - _timeline: [], - _accountData: Object.create(null), - _unreadNotifications: {}, - _summary: {}, - _readReceipts: {}, + "_currentState": Object.create(null), + "_timeline": [], + "_accountData": Object.create(null), + "_unreadNotifications": {}, + "_unreadThreadNotifications": {}, + "_summary": {}, + "_readReceipts": {}, }; } const currentData = this.joinRooms[roomId]; @@ -379,6 +386,9 @@ export class SyncAccumulator { if (data.unread_notifications) { currentData._unreadNotifications = data.unread_notifications; } + if (data.unread_thread_notifications) { + currentData._unreadThreadNotifications = data.unread_thread_notifications; + } if (data.summary) { const HEROES_KEY = "m.heroes"; const INVITED_COUNT_KEY = "m.invited_member_count"; @@ -537,6 +547,7 @@ export class SyncAccumulator { prev_batch: null, }, unread_notifications: roomData._unreadNotifications, + unread_thread_notifications: roomData._unreadThreadNotifications, summary: roomData._summary as IRoomSummary, }; // Add account data diff --git a/src/sync.ts b/src/sync.ts index 4abd4fb5bb2..063b22cbc02 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -138,6 +138,8 @@ interface ISyncParams { // eslint-disable-next-line camelcase set_presence?: SetPresence; _cacheBuster?: string | number; // not part of the API itself + unread_thread_notifications?: boolean; + "org.matrix.msc3773.unread_thread_notifications"?: boolean; } type WrappedRoom = T & { @@ -996,8 +998,10 @@ export class SyncApi { } const qps: ISyncParams = { - filter: filterId, - timeout: pollTimeout, + "filter": filterId, + "timeout": pollTimeout, + "unread_thread_notifications": true, + "org.matrix.msc3773.unread_thread_notifications": true, }; if (this.opts.disablePresence) { @@ -1299,6 +1303,27 @@ export class SyncApi { } } + const unreadThreadNotifications = joinObj.unread_thread_notifications; + if (unreadThreadNotifications) { + Object.entries(unreadThreadNotifications).forEach(([threadId, unreadNotification]) => { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Total, + unreadNotification.notification_count, + ); + + const hasUnreadNotification = + room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0; + if (!encrypted || (encrypted && hasUnreadNotification)) { + room.setThreadUnreadNotificationCount( + threadId, + NotificationCountType.Highlight, + unreadNotification.highlight_count, + ); + } + }); + } + joinObj.timeline = joinObj.timeline || {} as ITimeline; if (joinObj.isBrandNewRoom) { From c10c81afbe518a9716b87164cb0b5025f10b8378 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 18 Aug 2022 13:17:21 +0100 Subject: [PATCH 2/4] Setup sync filter properly --- src/filter.ts | 8 ++++++++ src/sync.ts | 5 +---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/filter.ts b/src/filter.ts index 79d57c5d582..af59cba99c8 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -221,6 +221,14 @@ export class Filter { setProp(this.definition, "room.timeline.limit", limit); } + /** + * Enable threads unread notification + * @param {boolean} enabled + */ + public setUnreadThreadNotifications(enabled: boolean) { + setProp(this.definition, "room.timeline.unread_thread_notifications", enabled); + } + setLazyLoadMembers(enabled: boolean) { setProp(this.definition, "room.state.lazy_load_members", !!enabled); } diff --git a/src/sync.ts b/src/sync.ts index b15f30daac6..18ce59e311c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -138,8 +138,6 @@ interface ISyncParams { // eslint-disable-next-line camelcase set_presence?: SetPresence; _cacheBuster?: string | number; // not part of the API itself - unread_thread_notifications?: boolean; - "org.matrix.msc3773.unread_thread_notifications"?: boolean; } type WrappedRoom = T & { @@ -662,6 +660,7 @@ export class SyncApi { const buildDefaultFilter = () => { const filter = new Filter(client.credentials.userId); filter.setTimelineLimit(this.opts.initialSyncLimit); + filter.setUnreadThreadNotifications(true); return filter; }; @@ -1000,8 +999,6 @@ export class SyncApi { const qps: ISyncParams = { "filter": filterId, "timeout": pollTimeout, - "unread_thread_notifications": true, - "org.matrix.msc3773.unread_thread_notifications": true, }; if (this.opts.disablePresence) { From 190fea1f64db614e6b7fc71dd5aff94e0c04ae52 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 18 Aug 2022 15:44:28 +0100 Subject: [PATCH 3/4] Fix unread room state updating --- src/models/room.ts | 43 +++++++++---------------------------------- src/sync.ts | 1 + 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index d4a112a5ff9..628c5c4b512 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -158,11 +158,11 @@ export type RoomEventHandlerMap = { type NotificationCount = Partial>; -export class Room extends TypedEventEmitter { +export class Room extends TimelineReceipts { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } private notificationCounts: NotificationCount = {}; - private threadNotifications: Record = {}; + public threadNotifications: Record = {}; private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -1163,10 +1163,13 @@ export class Room extends TypedEventEmitter } public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { - const notificationCount = this.threadNotifications[threadId]; - if (notificationCount) { - notificationCount[type] = count; - } + this.threadNotifications[threadId] = { + highlight: this.threadNotifications[threadId]?.highlight ?? 0, + total: this.threadNotifications[threadId]?.total ?? 0, + ...{ + [type]: count, + }, + }; } public setSummary(summary: IRoomSummary): void { @@ -2493,24 +2496,6 @@ export class Room extends TypedEventEmitter }); } - /** - * Gets the latest receipt for a given user in the room - * @param userId The id of the user for which we want the receipt - * @param ignoreSynthesized Whether to ignore synthesized receipts or not - * @param receiptType Optional. The type of the receipt we want to get - * @returns the latest receipts of the chosen type for the chosen user - */ - public getReadReceiptForUserId( - userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read, - ): IWrappedReceipt | null { - const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? []; - if (ignoreSynthesized) { - return realReceipt; - } - - return syntheticReceipt ?? realReceipt; - } - /** * Get the ID of the event that a given user has read up to, or null if we * have received no read receipts from them. @@ -2615,16 +2600,6 @@ export class Room extends TypedEventEmitter return false; } - /** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ - public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { - return this.receiptCacheByEventId[event.getId()] || []; - } - /** * Add a receipt event to the room. * @param {MatrixEvent} event The m.receipt event. diff --git a/src/sync.ts b/src/sync.ts index 18ce59e311c..4fe47cc3031 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1300,6 +1300,7 @@ export class SyncApi { } } + room.threadNotifications = {}; const unreadThreadNotifications = joinObj.unread_thread_notifications; if (unreadThreadNotifications) { Object.entries(unreadThreadNotifications).forEach(([threadId, unreadNotification]) => { From e5edf5bd903c72959b9231646a9a116bf475bc56 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 23 Aug 2022 11:39:05 +0200 Subject: [PATCH 4/4] Add e2ee support for notifications highlight --- src/client.ts | 95 +++++++++++++++++--------- src/models/room.ts | 117 -------------------------------- src/models/timeline-receipts.ts | 98 +++++++++++++------------- 3 files changed, 116 insertions(+), 194 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7631c383c15..fa060d51e1e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1048,37 +1048,7 @@ export class MatrixClient extends TypedEventEmitter { - const oldActions = event.getPushActions(); - const actions = this.getPushActionsForEvent(event, true); - - const room = this.getRoom(event.getRoomId()); - if (!room) return; - - const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); - - // Ensure the unread counts are kept up to date if the event is encrypted - // We also want to make sure that the notification count goes up if we already - // have encrypted events to avoid other code from resetting 'highlight' to zero. - const oldHighlight = !!oldActions?.tweaks?.highlight; - const newHighlight = !!actions?.tweaks?.highlight; - if (oldHighlight !== newHighlight || currentCount > 0) { - // TODO: Handle mentions received while the client is offline - // See also https://github.com/vector-im/element-web/issues/9069 - if (!room.hasUserReadEvent(this.getUserId(), event.getId())) { - let newCount = currentCount; - if (newHighlight && !oldHighlight) newCount++; - if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); - - // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); - if (totalCount < newCount) { - room.setUnreadNotificationCount(NotificationCountType.Total, newCount); - } - } - } - }); + this.on(MatrixEventEvent.Decrypted, this.recalculateNotifications); // Like above, we have to listen for read receipts from ourselves in order to // correctly handle notification counts on encrypted rooms. @@ -1130,6 +1100,69 @@ export class MatrixClient extends TypedEventEmitter 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/element-web/issues/9069 + const hasReadEvent = isThreadEvent + ? room.getThread(event.threadRootId).hasUserReadEvent(this.getUserId(), event.getId()) + : room.hasUserReadEvent(this.getUserId(), event.getId()); + + if (!hasReadEvent) { + let newCount = currentCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Highlight, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); + } + + // Fix 'Mentions Only' rooms from not having the right badge count + const totalCount = isThreadEvent + ? room.getThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total) + : room.getUnreadNotificationCount(NotificationCountType.Total); + + if (totalCount < newCount) { + if (isThreadEvent) { + room.setThreadUnreadNotificationCount( + event.threadRootId, + NotificationCountType.Total, + newCount, + ); + } else { + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); + } + } + } + } + } + /** * High level helper method to begin syncing and poll for new events. To listen for these * events, add a listener for {@link module:client~MatrixClient#event:"event"} diff --git a/src/models/room.ts b/src/models/room.ts index 628c5c4b512..cc5bcb2316c 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2483,123 +2483,6 @@ export class Room extends TimelineReceipts { } } - /** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return utils.isSupportedReceiptType(receipt.type); - }).map(function(receipt) { - return receipt.userId; - }); - } - - /** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { - // XXX: This is very very ugly and I hope I won't have to ever add a new - // receipt type here again. IMHO this should be done by the server in - // some more intelligent manner or the client should just use timestamps - - const timelineSet = this.getUnfilteredTimelineSet(); - const publicReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.Read, - ); - const privateReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.ReadPrivate, - ); - const unstablePrivateReadReceipt = this.getReadReceiptForUserId( - userId, - ignoreSynthesized, - ReceiptType.UnstableReadPrivate, - ); - - // If we have all, compare them - if (publicReadReceipt?.eventId && privateReadReceipt?.eventId && unstablePrivateReadReceipt?.eventId) { - const comparison1 = timelineSet.compareEventOrdering( - publicReadReceipt.eventId, - privateReadReceipt.eventId, - ); - const comparison2 = timelineSet.compareEventOrdering( - publicReadReceipt.eventId, - unstablePrivateReadReceipt.eventId, - ); - const comparison3 = timelineSet.compareEventOrdering( - privateReadReceipt.eventId, - unstablePrivateReadReceipt.eventId, - ); - if (comparison1 && comparison2 && comparison3) { - return (comparison1 > 0) - ? ((comparison2 > 0) ? publicReadReceipt.eventId : unstablePrivateReadReceipt.eventId) - : ((comparison3 > 0) ? privateReadReceipt.eventId : unstablePrivateReadReceipt.eventId); - } - } - - let latest = privateReadReceipt; - [unstablePrivateReadReceipt, publicReadReceipt].forEach((receipt) => { - if (receipt?.data?.ts > latest?.data?.ts) { - latest = receipt; - } - }); - if (latest?.eventId) return latest?.eventId; - - // The more less likely it is for a read receipt to drift out of date - // the bigger is its precedence - return ( - privateReadReceipt?.eventId ?? - unstablePrivateReadReceipt?.eventId ?? - publicReadReceipt?.eventId ?? - null - ); - } - - /** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; - } - /** * Add a receipt event to the room. * @param {MatrixEvent} event The m.receipt event. diff --git a/src/models/timeline-receipts.ts b/src/models/timeline-receipts.ts index 90d255067a0..76324f3711b 100644 --- a/src/models/timeline-receipts.ts +++ b/src/models/timeline-receipts.ts @@ -101,7 +101,19 @@ export abstract class TimelineReceipts< private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] } public abstract getUnfilteredTimelineSet(): EventTimelineSet; - public abstract timeline: MatrixEvent[]; + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return utils.isSupportedReceiptType(receipt.type); + }).map(function(receipt) { + return receipt.userId; + }); + } /** * Gets the latest receipt for a given user in the room @@ -191,6 +203,40 @@ export abstract class TimelineReceipts< ); } + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } + public addReceiptToStructure( eventId: string, receiptType: ReceiptType, @@ -308,50 +354,10 @@ export abstract class TimelineReceipts< this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); } - /** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return utils.isSupportedReceiptType(receipt.type); - }).map(function(receipt) { - return receipt.userId; - }); - } - - /** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; + public get timeline(): MatrixEvent[] { + return this + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents(); } }