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/filter.ts b/src/filter.ts index 663ba1bb932..af59cba99c8 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; @@ -220,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/models/room.ts b/src/models/room.ts index 510f3d0f741..cc5bcb2316c 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -156,10 +156,13 @@ export type RoomEventHandlerMap = { [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; } & ThreadHandlerMap & MatrixEventHandlerMap; +type NotificationCount = Partial>; + export class Room extends TimelineReceipts { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } - private notificationCounts: Partial> = {}; + private notificationCounts: NotificationCount = {}; + public threadNotifications: Record = {}; private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room @@ -1120,6 +1123,22 @@ export class Room extends TimelineReceipts { 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' @@ -1127,7 +1146,7 @@ export class Room extends TimelineReceipts { * for this type. */ public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { - return this.notificationCounts[type]; + return this.notificationCounts[type] ?? 0; } /** @@ -1139,6 +1158,20 @@ export class Room extends TimelineReceipts { 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 { + this.threadNotifications[threadId] = { + highlight: this.threadNotifications[threadId]?.highlight ?? 0, + total: this.threadNotifications[threadId]?.total ?? 0, + ...{ + [type]: count, + }, + }; + } + public setSummary(summary: IRoomSummary): void { const heroes = summary["m.heroes"]; const joinedCount = summary["m.joined_member_count"]; @@ -2450,151 +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; - }); - } - - /** - * 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. - * @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; - } - - /** - * 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/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(); } } diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 7e8c9deccba..b75a7a9bab8 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 a16f08f8925..4fe47cc3031 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -660,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; }; @@ -996,8 +997,8 @@ export class SyncApi { } const qps: ISyncParams = { - filter: filterId, - timeout: pollTimeout, + "filter": filterId, + "timeout": pollTimeout, }; if (this.opts.disablePresence) { @@ -1299,6 +1300,28 @@ export class SyncApi { } } + room.threadNotifications = {}; + 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) {