FEATURE: Display unread and new counts for messages. (#14059)

FEATURE: Display unread and new counts for messages. (#14059)

There are certain design decisions that were made in this commit.

Private messages implements its own version of topic tracking state because there are significant differences between regular and private_message topics. Regular topics have to track categories and tags while private messages do not. It is much easier to design the new topic tracking state if we maintain two different classes, instead of trying to mash this two worlds together.

One MessageBus channel per user and one MessageBus channel per group. This allows each user and each group to have their own channel backlog instead of having one global channel which requires the client to filter away unrelated messages.

diff --git a/app/assets/javascripts/discourse/app/controllers/user-private-messages.js b/app/assets/javascripts/discourse/app/controllers/user-private-messages.js
index f50f9ba..2ff2c1f 100644
--- a/app/assets/javascripts/discourse/app/controllers/user-private-messages.js
+++ b/app/assets/javascripts/discourse/app/controllers/user-private-messages.js
@@ -66,12 +66,28 @@ export default Controller.extend({
     return pmView === VIEW_NAME_WARNINGS && !viewingSelf && !isAdmin;
   },
 
-  @discourseComputed("model.groups")
-  inboxes(groups) {
-    const groupsWithMessages = groups?.filter((group) => {
-      return group.has_messages;
-    });
+  @discourseComputed("pmTopicTrackingState.newIncoming.[]", "selectedInbox")
+  newLinkText() {
+    return this._linkText("new");
+  },
+
+  @discourseComputed("selectedInbox", "pmTopicTrackingState.newIncoming.[]")
+  unreadLinkText() {
+    return this._linkText("unread");
+  },
+
+  _linkText(type) {
+    const count = this.pmTopicTrackingState?.lookupCount(type) || 0;
+
+    if (count === 0) {
+      return I18n.t(`user.messages.${type}`);
+    } else {
+      return I18n.t(`user.messages.${type}_with_count`, { count });
+    }
+  },
 
+  @discourseComputed("model.groupsWithMessages")
+  inboxes(groupsWithMessages) {
     if (!groupsWithMessages || groupsWithMessages.length === 0) {
       return [];
     }
diff --git a/app/assets/javascripts/discourse/app/controllers/user-topics-list.js b/app/assets/javascripts/discourse/app/controllers/user-topics-list.js
index 4b3adfb..195adb4 100644
--- a/app/assets/javascripts/discourse/app/controllers/user-topics-list.js
+++ b/app/assets/javascripts/discourse/app/controllers/user-topics-list.js
@@ -1,8 +1,6 @@
 import Controller, { inject as controller } from "@ember/controller";
-import discourseComputed, {
-  observes,
-  on,
-} from "discourse-common/utils/decorators";
+import discourseComputed, { observes } from "discourse-common/utils/decorators";
+import { reads } from "@ember/object/computed";
 import BulkTopicSelection from "discourse/mixins/bulk-topic-selection";
 import { action } from "@ember/object";
 import Topic from "discourse/models/topic";
@@ -18,14 +16,9 @@ export default Controller.extend(BulkTopicSelection, {
 
   hideCategory: false,
   showPosters: false,
-  incomingCount: 0,
   channel: null,
   tagsForUser: null,
-
-  @on("init")
-  _initialize() {
-    this.newIncoming = [];
-  },
+  pmTopicTrackingState: null,
 
   saveScrollPosition() {
     this.session.set("topicListScrollPosition", $(window).scrollTop());
@@ -36,10 +29,7 @@ export default Controller.extend(BulkTopicSelection, {
     this.set("application.showFooter", !this.get("model.canLoadMore"));
   },
 
-  @discourseComputed("incomingCount")
-  hasIncoming(incomingCount) {
-    return incomingCount > 0;
-  },
+  incomingCount: reads("pmTopicTrackingState.newIncoming.length"),
 
   @discourseComputed("filter", "model.topics.length")
   showResetNew(filter, hasTopics) {
@@ -51,31 +41,16 @@ export default Controller.extend(BulkTopicSelection, {
     return filter === UNREAD_FILTER && hasTopics;
   },
 
-  subscribe(channel) {
-    this.set("channel", channel);
-
-    this.messageBus.subscribe(channel, (data) => {
-      if (this.newIncoming.indexOf(data.topic_id) === -1) {
-        this.newIncoming.push(data.topic_id);
-        this.incrementProperty("incomingCount");
-      }
-    });
+  subscribe() {
+    this.pmTopicTrackingState?.trackIncoming(
+      this.inbox,
+      this.filter,
+      this.group
+    );
   },
 
   unsubscribe() {
-    const channel = this.channel;
-    if (channel) {
-      this.messageBus.unsubscribe(channel);
-    }
-    this._resetTracking();
-    this.set("channel", null);
-  },
-
-  _resetTracking() {
-    this.setProperties({
-      newIncoming: [],
-      incomingCount: 0,
-    });
+    this.pmTopicTrackingState?.resetTracking();
   },
 
   @action
@@ -105,8 +80,8 @@ export default Controller.extend(BulkTopicSelection, {
 
   @action
   showInserted() {
-    this.model.loadBefore(this.newIncoming);
-    this._resetTracking();
+    this.model.loadBefore(this.pmTopicTrackingState.newIncoming);
+    this.pmTopicTrackingState.resetTracking();
     return false;
   },
 });
diff --git a/app/assets/javascripts/discourse/app/models/private-message-topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/private-message-topic-tracking-state.js
new file mode 100644
index 0000000..4fcb8f9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/models/private-message-topic-tracking-state.js
@@ -0,0 +1,196 @@
+import EmberObject from "@ember/object";
+import {
+  ARCHIVE_FILTER,
+  INBOX_FILTER,
+  NEW_FILTER,
+  UNREAD_FILTER,
+} from "discourse/routes/build-private-messages-route";
+import { NotificationLevels } from "discourse/lib/notification-levels";
+
+// See private_message_topic_tracking_state.rb for documentation
+const PrivateMessageTopicTrackingState = EmberObject.extend({
+  CHANNEL_PREFIX: "/private-message-topic-tracking-state",
+
+  inbox: null,
+  filter: null,
+  activeGroup: null,
+
+  startTracking(data) {
+    this.states = new Map();
+    this.newIncoming = [];
+    this._loadStates(data);
+    this.establishChannels();
+  },
+
+  establishChannels() {
+    this.messageBus.subscribe(
+      this._userChannel(this.user.id),
+      this._processMessage.bind(this)
+    );
+
+    this.user.groupsWithMessages?.forEach((group) => {
+      this.messageBus.subscribe(
+        this._groupChannel(group.id),
+        this._processMessage.bind(this)
+      );
+    });
+  },
+
+  stopTracking() {
+    this.messageBus.unsubscribe(this._userChannel(this.user.id));
+
+    this.user.groupsWithMessages?.forEach((group) => {
+      this.messageBus.unsubscribe(this._groupChannel(group.id));
+    });
+  },
+
+  lookupCount(type) {
+    const typeFilterFn = type === "new" ? this._isNew : this._isUnread;
+    let filterFn;
+
+    if (this.inbox === "user") {
+      filterFn = this._isPersonal.bind(this);
+    } else if (this.inbox === "group") {
+      filterFn = this._isGroup.bind(this);
+    }
+
+    return Array.from(this.states.values()).filter((topic) => {
+      return typeFilterFn(topic) && (!filterFn || filterFn(topic));
+    }).length;
+  },
+
+  trackIncoming(inbox, filter, group) {
+    this.setProperties({ inbox, filter, activeGroup: group });
+  },
+
+  resetTracking() {
+    if (this.inbox) {
+      this.set("newIncoming", []);
+    }
+  },
+
+  _userChannel(userId) {
+    return `${this.CHANNEL_PREFIX}/user/${userId}`;
+  },
+
+  _groupChannel(groupId) {
+    return `${this.CHANNEL_PREFIX}/group/${groupId}`;
+  },
+
+  _isNew(topic) {
+    return (
+      !topic.last_read_post_number &&
+      ((topic.notification_level !== 0 && !topic.notification_level) ||
+        topic.notification_level >= NotificationLevels.TRACKING) &&
+      !topic.is_seen
+    );
+  },
+
+  _isUnread(topic) {
+    return (
+      topic.last_read_post_number &&
+      topic.last_read_post_number < topic.highest_post_number &&
+      topic.notification_level >= NotificationLevels.TRACKING
+    );
+  },
+
+  _isPersonal(topic) {
+    const groups = this.user.groups;
+
+    if (groups.length === 0) {
+      return true;
+    }
+
+    return !groups.some((group) => {
+      return topic.group_ids?.includes(group.id);
+    });
+  },
+
+  _isGroup(topic) {
+    return this.user.groups.some((group) => {
+      return (
+        group.name === this.activeGroup.name &&
+        topic.group_ids?.includes(group.id)
+      );
+    });
+  },
+
+  _processMessage(message) {
+    switch (message.message_type) {
+      case "new_topic":
+        this._modifyState(message.topic_id, message.payload);
+
+        if (
+          [NEW_FILTER, INBOX_FILTER].includes(this.filter) &&
+          this._shouldDisplayMessageForInbox(message)
+        ) {
+          this._notifyIncoming(message.topic_id);
+        }
+
+        break;
+      case "unread":

[... diff too long, it was truncated ...]

GitHub sha: f66007ec83b62169b5c41016eecd40c72f27028f

This commit appears in #14059 which was approved by martin. It was merged by tgxworld.

This criteria doesn’t take into account the user_option.new_topic_duration_minutes setting. For example, on Meta I have this set in my user preferences:

Screenshot 2021-09-13 at 17 00 22

And therefore I have 0 ‘new’ private messages. However, the count in the sidebar still shows a few old topics which I’ve never opened:

We should make sure to sync up the server-side and client-side criteria here.

Something weird is happening for me when I visit one of the messages pages, and then hit ‘refresh’ in the browser. If I visit Discourse Meta - The Official Support Forum for Discourse directly, then the new/unread counts do not appear. As soon as I click another tab in the sidebar, the counts appear.

As well as the ‘tags’ page, the same thing happens when I visit the ‘new’ page directly. This might be because I have no ‘new’ topics :thinking: