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 f50f9bad576..2ff2c1fc95e 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 4b3adfba487..195adb4fb00 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 00000000000..4fcb8f96925
--- /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":
+ this._modifyState(message.topic_id, message.payload);
+
+ if (
+ [UNREAD_FILTER, INBOX_FILTER].includes(this.filter) &&
+ this._shouldDisplayMessageForInbox(message)
+ ) {
+ this._notifyIncoming(message.topic_id);
+ }
+
+ break;
+ case "archive":
+ if (
+ [INBOX_FILTER, ARCHIVE_FILTER].includes(this.filter) &&
+ ["user", "all"].includes(this.inbox)
+ ) {
+ this._notifyIncoming(message.topic_id);
+ }
+ break;
+ case "group_archive":
+ if (
+ [INBOX_FILTER, ARCHIVE_FILTER].includes(this.filter) &&
+ (this.inbox === "all" || this._displayMessageForGroupInbox(message))
+ ) {
+ this._notifyIncoming(message.topic_id);
+ }
+ }
+ },
+
+ _displayMessageForGroupInbox(message) {
+ return (
+ this.inbox === "group" &&
+ message.payload.group_ids.includes(this.activeGroup.id)
+ );
+ },
+
+ _shouldDisplayMessageForInbox(message) {
+ return (
+ this.inbox === "all" ||
+ this._displayMessageForGroupInbox(message) ||
+ (this.inbox === "user" &&
+ (message.payload.group_ids.length === 0 ||
+ this.currentUser.groups.filter((group) => {
+ return message.payload.group_ids.includes(group.id);
+ }).length === 0))
+ );
+ },
+
+ _notifyIncoming(topicId) {
+ if (this.newIncoming.indexOf(topicId) === -1) {
+ this.newIncoming.pushObject(topicId);
+ }
+ },
+
+ _loadStates(data) {
+ (data || []).forEach((topic) => {
+ this._modifyState(topic.topic_id, topic);
+ });
+ },
+
+ _modifyState(topicId, data) {
+ this.states.set(topicId, data);
+ },
+});
+
+export default PrivateMessageTopicTrackingState;
diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js
index fc28a3b31f0..775b40db8ee 100644
--- a/app/assets/javascripts/discourse/app/models/user.js
+++ b/app/assets/javascripts/discourse/app/models/user.js
@@ -1,7 +1,7 @@
import EmberObject, { computed, get, getProperties } from "@ember/object";
import cookie, { removeCookie } from "discourse/lib/cookie";
import { defaultHomepage, escapeExpression } from "discourse/lib/utilities";
-import { equal, gt, or } from "@ember/object/computed";
+import { equal, filterBy, gt, or } from "@ember/object/computed";
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
import { A } from "@ember/array";
import Badge from "discourse/models/badge";
@@ -562,6 +562,8 @@ const User = RestModel.extend({
});
},
+ groupsWithMessages: filterBy("groups", "has_messages", true),
+
@discourseComputed("filteredGroups", "numGroupsToDisplay")
displayGroups(filteredGroups, numGroupsToDisplay) {
const groups = filteredGroups.slice(0, numGroupsToDisplay);
diff --git a/app/assets/javascripts/discourse/app/routes/build-private-messages-group-route.js b/app/assets/javascripts/discourse/app/routes/build-private-messages-group-route.js
index c82d36ccdf4..a96df1dc42b 100644
--- a/app/assets/javascripts/discourse/app/routes/build-private-messages-group-route.js
+++ b/app/assets/javascripts/discourse/app/routes/build-private-messages-group-route.js
@@ -57,16 +57,16 @@ export default (inboxType, filter) => {
setupController() {
this._super.apply(this, arguments);
- this.controllerFor("user-private-messages").set("group", this.group);
- this.controllerFor("user-topics-list").set("group", this.group);
- if (filter) {
- this.controllerFor("user-topics-list").subscribe(
- `/private-messages/group/${this.get(
- "groupName"
- ).toLowerCase()}/${filter}`
- );
- }
+ const userTopicsListController = this.controllerFor("user-topics-list");
+ userTopicsListController.set("group", this.group);
+
+ userTopicsListController.set(
+ "pmTopicTrackingState.activeGroup",
+ this.group
+ );
+
+ this.controllerFor("user-private-messages").set("group", this.group);
},
dismissReadOptions() {
diff --git a/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js b/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
index cf2a8c8ae38..c2608d7606c 100644
--- a/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
+++ b/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
@@ -6,6 +6,8 @@ import { action } from "@ember/object";
export const NEW_FILTER = "new";
export const UNREAD_FILTER = "unread";
+export const INBOX_FILTER = "inbox";
+export const ARCHIVE_FILTER = "archive";
// A helper to build a user topic list route
export default (inboxType, path, filter) => {
@@ -42,13 +44,13 @@ export default (inboxType, path, filter) => {
setupController() {
this._super.apply(this, arguments);
- if (filter) {
- this.controllerFor("user-topics-list").subscribe(
- `/private-messages/${filter}`
- );
- }
+ const userPrivateMessagesController = this.controllerFor(
+ "user-private-messages"
+ );
- this.controllerFor("user-topics-list").setProperties({
+ const userTopicsListController = this.controllerFor("user-topics-list");
+
+ userTopicsListController.setProperties({
hideCategory: true,
showPosters: true,
tagsForUser: this.modelFor("user").get("username_lower"),
@@ -57,9 +59,13 @@ export default (inboxType, path, filter) => {
filter: filter,
group: null,
inbox: inboxType,
+ pmTopicTrackingState:
+ userPrivateMessagesController.pmTopicTrackingState,
});
- this.controllerFor("user-private-messages").setProperties({
+ userTopicsListController.subscribe();
+
+ userPrivateMessagesController.setProperties({
archive: false,
pmView: inboxType,
group: null,
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-archive.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-archive.js
index d12d153a5bb..9861501713e 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-archive.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-archive.js
@@ -1,7 +1,9 @@
-import createPMRoute from "discourse/routes/build-private-messages-route";
+import createPMRoute, {
+ ARCHIVE_FILTER,
+} from "discourse/routes/build-private-messages-route";
export default createPMRoute(
- "archive",
+ "all",
"private-messages-all-archive",
- "archive"
+ ARCHIVE_FILTER
);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-group-archive.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-group-archive.js
index 726f6e15017..de84bea4147 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-group-archive.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-group-archive.js
@@ -1,3 +1,4 @@
import createPMRoute from "discourse/routes/build-private-messages-group-route";
+import { ARCHIVE_FILTER } from "discourse/routes/build-private-messages-route";
-export default createPMRoute("group", "archive");
+export default createPMRoute("group", ARCHIVE_FILTER);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-group.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-group.js
index ce17c2fc0a5..e8c39ac074c 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-group.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-group.js
@@ -1,3 +1,4 @@
import createPMRoute from "discourse/routes/build-private-messages-group-route";
+import { INBOX_FILTER } from "discourse/routes/build-private-messages-route";
-export default createPMRoute("group", "inbox");
+export default createPMRoute("group", INBOX_FILTER);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-index.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-index.js
index fa4024d5289..aab9f5a98ba 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-index.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-index.js
@@ -1,3 +1,5 @@
-import createPMRoute from "discourse/routes/build-private-messages-route";
+import createPMRoute, {
+ INBOX_FILTER,
+} from "discourse/routes/build-private-messages-route";
-export default createPMRoute("all", "private-messages-all", "inbox");
+export default createPMRoute("all", "private-messages-all", INBOX_FILTER);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-personal-archive.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-personal-archive.js
index 17ab77b75cb..a6128847f7c 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-personal-archive.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-personal-archive.js
@@ -1,3 +1,9 @@
-import createPMRoute from "discourse/routes/build-private-messages-route";
+import createPMRoute, {
+ ARCHIVE_FILTER,
+} from "discourse/routes/build-private-messages-route";
-export default createPMRoute("user", "private-messages-archive", "archive");
+export default createPMRoute(
+ "user",
+ "private-messages-archive",
+ ARCHIVE_FILTER
+);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-personal.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-personal.js
index bedfe937aba..fd0a1f8c10e 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages-personal.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-personal.js
@@ -1,3 +1,5 @@
-import createPMRoute from "discourse/routes/build-private-messages-route";
+import createPMRoute, {
+ INBOX_FILTER,
+} from "discourse/routes/build-private-messages-route";
-export default createPMRoute("user", "private-messages", "inbox");
+export default createPMRoute("user", "private-messages", INBOX_FILTER);
diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages.js b/app/assets/javascripts/discourse/app/routes/user-private-messages.js
index 34e8b488c1d..9c0673865e0 100644
--- a/app/assets/javascripts/discourse/app/routes/user-private-messages.js
+++ b/app/assets/javascripts/discourse/app/routes/user-private-messages.js
@@ -1,6 +1,9 @@
import Composer from "discourse/models/composer";
import DiscourseRoute from "discourse/routes/discourse";
import Draft from "discourse/models/draft";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import PrivateMessageTopicTrackingState from "discourse/models/private-message-topic-tracking-state";
export default DiscourseRoute.extend({
renderTemplate() {
@@ -8,11 +11,36 @@ export default DiscourseRoute.extend({
},
model() {
- return this.modelFor("user");
+ const user = this.modelFor("user");
+ return ajax(`/u/${user.username}/private-message-topic-tracking-state`)
+ .then((pmTopicTrackingStateData) => {
+ return {
+ user,
+ pmTopicTrackingStateData,
+ };
+ })
+ .catch((e) => {
+ popupAjaxError(e);
+ return { user };
+ });
},
- setupController(controller, user) {
- controller.set("model", user);
+ setupController(controller, model) {
+ const user = model.user;
+
+ const pmTopicTrackingState = PrivateMessageTopicTrackingState.create({
+ messageBus: controller.messageBus,
+ user,
+ });
+
+ pmTopicTrackingState.startTracking(model.pmTopicTrackingStateData);
+
+ controller.setProperties({
+ model: user,
+ pmTopicTrackingState,
+ });
+
+ this.set("pmTopicTrackingState", pmTopicTrackingState);
if (this.currentUser) {
const composerController = this.controllerFor("composer");
@@ -30,6 +58,10 @@ export default DiscourseRoute.extend({
}
},
+ deactivate() {
+ this.pmTopicTrackingState.stopTracking();
+ },
+
actions: {
refresh() {
this.refresh();
diff --git a/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs b/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs
index e6b0a5d237c..4e9d4d138e3 100644
--- a/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs
@@ -13,7 +13,7 @@
showDismissRead=showDismissRead
resetNew=(action "resetNew")}}
- {{#if hasIncoming}}
+ {{#if (gt incomingCount 0)}}
{{count-i18n key="topic_count_" suffix="latest" count=incomingCount}}
diff --git a/app/assets/javascripts/discourse/app/templates/user/messages.hbs b/app/assets/javascripts/discourse/app/templates/user/messages.hbs
index 415a6ae11a5..fbe1bf4f2f3 100644
--- a/app/assets/javascripts/discourse/app/templates/user/messages.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user/messages.hbs
@@ -32,13 +32,13 @@
{{/link-to}}
- {{#link-to "userPrivateMessages.new" model}}
- {{i18n "user.messages.new"}}
+ {{#link-to "userPrivateMessages.new" model class="new"}}
+ {{newLinkText}}
{{/link-to}}
- {{#link-to "userPrivateMessages.unread" model}}
- {{i18n "user.messages.unread"}}
+ {{#link-to "userPrivateMessages.unread" model class="unread"}}
+ {{unreadLinkText}}
{{/link-to}}
@@ -55,13 +55,13 @@
{{/link-to}}
- {{#link-to "userPrivateMessages.groupNew" group.name}}
- {{i18n "user.messages.new"}}
+ {{#link-to "userPrivateMessages.groupNew" group.name class="new"}}
+ {{newLinkText}}
{{/link-to}}
- {{#link-to "userPrivateMessages.groupUnread" group.name}}
- {{i18n "user.messages.unread"}}
+ {{#link-to "userPrivateMessages.groupUnread" group.name class="unread"}}
+ {{unreadLinkText}}
{{/link-to}}
@@ -83,13 +83,13 @@
{{/link-to}}
- {{#link-to "userPrivateMessages.personalNew" model}}
- {{i18n "user.messages.new"}}
+ {{#link-to "userPrivateMessages.personalNew" model class="new"}}
+ {{newLinkText}}
{{/link-to}}
- {{#link-to "userPrivateMessages.personalUnread" model}}
- {{i18n "user.messages.unread"}}
+ {{#link-to "userPrivateMessages.personalUnread" model class="unread"}}
+ {{unreadLinkText}}
{{/link-to}}
@@ -101,7 +101,7 @@
{{#if displayGlobalFilters}}
{{#if pmTaggingEnabled}}
-
+
{{#link-to "userPrivateMessages.tags" model}}
{{i18n "user.messages.tags"}}
{{/link-to}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js
index 32e00a8cfaf..dac9e7d27be 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js
@@ -1,10 +1,13 @@
+import { visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import I18n from "I18n";
import {
acceptance,
count,
exists,
+ publishToMessageBus,
+ query,
} from "discourse/tests/helpers/qunit-helpers";
-import { visit } from "@ember/test-helpers";
-import { test } from "qunit";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { PERSONAL_INBOX } from "discourse/controllers/user-private-messages";
@@ -72,10 +75,13 @@ acceptance(
[
"/topics/private-messages-all-new/:username.json",
"/topics/private-messages-all-unread/:username.json",
+ "/topics/private-messages-all-archive/:username.json",
"/topics/private-messages-new/:username.json",
"/topics/private-messages-unread/:username.json",
+ "/topics/private-messages-archive/:username.json",
"/topics/private-messages-group/:username/:group_name/new.json",
"/topics/private-messages-group/:username/:group_name/unread.json",
+ "/topics/private-messages-group/:username/:group_name/archive.json",
].forEach((url) => {
server.get(url, () => {
let topics;
@@ -153,6 +159,227 @@ acceptance(
});
});
+ const publishUnreadToMessageBus = function (group_ids) {
+ publishToMessageBus("/private-message-topic-tracking-state/user/5", {
+ topic_id: Math.random(),
+ message_type: "unread",
+ payload: {
+ last_read_post_number: 1,
+ highest_post_number: 2,
+ notification_level: 2,
+ group_ids: group_ids || [],
+ },
+ });
+ };
+
+ const publishNewToMessageBus = function (group_ids) {
+ publishToMessageBus("/private-message-topic-tracking-state/user/5", {
+ topic_id: Math.random(),
+ message_type: "new_topic",
+ payload: {
+ last_read_post_number: null,
+ highest_post_number: 1,
+ group_ids: group_ids || [],
+ },
+ });
+ };
+
+ const publishArchiveToMessageBus = function () {
+ publishToMessageBus("/private-message-topic-tracking-state/user/5", {
+ topic_id: Math.random(),
+ message_type: "archive",
+ });
+ };
+
+ const publishGroupArchiveToMessageBus = function (group_ids) {
+ publishToMessageBus(
+ `/private-message-topic-tracking-state/group/${group_ids[0]}`,
+ {
+ topic_id: Math.random(),
+ message_type: "group_archive",
+ payload: {
+ group_ids: group_ids,
+ },
+ }
+ );
+ };
+
+ test("incoming archive message on all and archive filter", async function (assert) {
+ for (const url of [
+ "/u/charlie/messages",
+ "/u/charlie/messages/archive",
+ "/u/charlie/messages/personal",
+ "/u/charlie/messages/personal/archive",
+ ]) {
+ await visit(url);
+
+ publishArchiveToMessageBus();
+
+ await visit(url); // wait for re-render
+
+ assert.ok(
+ exists(".show-mores"),
+ `${url} displays the topic incoming info`
+ );
+ }
+
+ for (const url of [
+ "/u/charlie/messages/group/awesome_group/archive",
+ "/u/charlie/messages/group/awesome_group",
+ ]) {
+ await visit(url);
+
+ publishArchiveToMessageBus();
+
+ await visit(url); // wait for re-render
+
+ assert.ok(
+ !exists(".show-mores"),
+ `${url} does not display the topic incoming info`
+ );
+ }
+ });
+
+ test("incoming group archive message on all and archive filter", async function (assert) {
+ for (const url of [
+ "/u/charlie/messages",
+ "/u/charlie/messages/archive",
+ "/u/charlie/messages/group/awesome_group",
+ "/u/charlie/messages/group/awesome_group/archive",
+ ]) {
+ await visit(url);
+
+ publishGroupArchiveToMessageBus([14]);
+
+ await visit(url); // wait for re-render
+
+ assert.ok(
+ exists(".show-mores"),
+ `${url} displays the topic incoming info`
+ );
+ }
+
+ for (const url of [
+ "/u/charlie/messages/personal",
+ "/u/charlie/messages/personal/archive",
+ ]) {
+ await visit(url);
+
+ publishGroupArchiveToMessageBus([14]);
+
+ await visit(url); // wait for re-render
+
+ assert.ok(
+ !exists(".show-mores"),
+ `${url} does not display the topic incoming info`
+ );
+ }
+ });
+
+ test("incoming unread and new messages on all filter", async function (assert) {
+ await visit("/u/charlie/messages");
+
+ publishUnreadToMessageBus();
+ publishNewToMessageBus();
+
+ await visit("/u/charlie/messages"); // wait for re-render
+
+ assert.equal(
+ query(".messages-nav li a.new").innerText.trim(),
+ I18n.t("user.messages.new_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.equal(
+ query(".messages-nav li a.unread").innerText.trim(),
+ I18n.t("user.messages.unread_with_count", { count: 1 }),
+ "displays the right count"
+ );
+ });
+
+ test("incoming new messages while viewing new", async function (assert) {
+ await visit("/u/charlie/messages/new");
+
+ publishNewToMessageBus();
+
+ await visit("/u/charlie/messages/new"); // wait for re-render
+
+ assert.equal(
+ query(".messages-nav li a.new").innerText.trim(),
+ I18n.t("user.messages.new_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.ok(exists(".show-mores"), "displays the topic incoming info");
+ });
+
+ test("incoming unread messages while viewing unread", async function (assert) {
+ await visit("/u/charlie/messages/unread");
+
+ publishUnreadToMessageBus();
+
+ await visit("/u/charlie/messages/unread"); // wait for re-render
+
+ assert.equal(
+ query(".messages-nav li a.unread").innerText.trim(),
+ I18n.t("user.messages.unread_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.ok(exists(".show-mores"), "displays the topic incoming info");
+ });
+
+ test("incoming unread messages while viewing group unread", async function (assert) {
+ await visit("/u/charlie/messages/group/awesome_group/unread");
+
+ publishUnreadToMessageBus([14]);
+ publishNewToMessageBus([14]);
+
+ await visit("/u/charlie/messages/group/awesome_group/unread"); // wait for re-render
+
+ assert.equal(
+ query(".messages-nav li a.unread").innerText.trim(),
+ I18n.t("user.messages.unread_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.equal(
+ query(".messages-nav li a.new").innerText.trim(),
+ I18n.t("user.messages.new_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.ok(exists(".show-mores"), "displays the topic incoming info");
+
+ await visit("/u/charlie/messages/unread");
+
+ assert.equal(
+ query(".messages-nav li a.unread").innerText.trim(),
+ I18n.t("user.messages.unread_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ assert.equal(
+ query(".messages-nav li a.new").innerText.trim(),
+ I18n.t("user.messages.new_with_count", { count: 1 }),
+ "displays the right count"
+ );
+
+ await visit("/u/charlie/messages/personal/unread");
+
+ assert.equal(
+ query(".messages-nav li a.unread").innerText.trim(),
+ I18n.t("user.messages.unread"),
+ "displays the right count"
+ );
+
+ assert.equal(
+ query(".messages-nav li a.new").innerText.trim(),
+ I18n.t("user.messages.new"),
+ "displays the right count"
+ );
+ });
+
test("dismissing all unread messages", async function (assert) {
await visit("/u/charlie/messages/unread");
diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
index be32ddfcdf3..96ae6e91b5c 100644
--- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
+++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
@@ -217,6 +217,10 @@ export function applyDefaultHandlers(pretender) {
});
});
+ pretender.get("/u/:username/private-message-topic-tracking-state", () => {
+ return response([]);
+ });
+
pretender.get("/topics/feature_stats.json", () => {
return response({
pinned_in_category_count: 0,
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 83ee6ce9a34..f151b340264 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -316,6 +316,21 @@ class UsersController < ApplicationController
render json: MultiJson.dump(serializer)
end
+ def private_message_topic_tracking_state
+ user = fetch_user_from_params
+ guardian.ensure_can_edit!(user)
+
+ report = PrivateMessageTopicTrackingState.report(user)
+
+ serializer = ActiveModel::ArraySerializer.new(
+ report,
+ each_serializer: PrivateMessageTopicTrackingStateSerializer,
+ scope: guardian
+ )
+
+ render json: MultiJson.dump(serializer)
+ end
+
def badge_title
params.require(:user_badge_id)
diff --git a/app/jobs/regular/post_update_topic_tracking_state.rb b/app/jobs/regular/post_update_topic_tracking_state.rb
index 6eaaeb288b4..c3d4610f802 100644
--- a/app/jobs/regular/post_update_topic_tracking_state.rb
+++ b/app/jobs/regular/post_update_topic_tracking_state.rb
@@ -5,8 +5,21 @@ module Jobs
def execute(args)
post = Post.find_by(id: args[:post_id])
+ return if !post&.topic
- if post && post.topic
+ topic = post.topic
+
+ if topic.private_message?
+ if post.post_number > 1
+ PrivateMessageTopicTrackingState.publish_unread(post)
+ end
+
+ TopicGroup.new_message_update(
+ topic.last_poster,
+ topic.id,
+ post.post_number
+ )
+ else
TopicTrackingState.publish_unmuted(post.topic)
if post.post_number > 1
TopicTrackingState.publish_muted(post.topic)
diff --git a/app/models/group_archived_message.rb b/app/models/group_archived_message.rb
index 6d31b94d206..3b481c86f12 100644
--- a/app/models/group_archived_message.rb
+++ b/app/models/group_archived_message.rb
@@ -9,7 +9,7 @@ class GroupArchivedMessage < ActiveRecord::Base
destroyed = GroupArchivedMessage.where(group_id: group_id, topic_id: topic_id).destroy_all
trigger(:move_to_inbox, group_id, topic_id)
MessageBus.publish("/topic/#{topic_id}", { type: "move_to_inbox" }, group_ids: [group_id])
- publish_topic_tracking_state(topic)
+ publish_topic_tracking_state(topic, group_id)
set_imap_sync(topic_id) if !opts[:skip_imap_sync] && destroyed.present?
end
@@ -19,7 +19,7 @@ class GroupArchivedMessage < ActiveRecord::Base
GroupArchivedMessage.create!(group_id: group_id, topic_id: topic_id)
trigger(:archive_message, group_id, topic_id)
MessageBus.publish("/topic/#{topic_id}", { type: "archived" }, group_ids: [group_id])
- publish_topic_tracking_state(topic)
+ publish_topic_tracking_state(topic, group_id)
set_imap_sync(topic_id) if !opts[:skip_imap_sync] && destroyed.blank?
end
@@ -31,20 +31,18 @@ class GroupArchivedMessage < ActiveRecord::Base
end
end
- private
-
- def self.publish_topic_tracking_state(topic)
- TopicTrackingState.publish_private_message(
- topic, group_archive: true
- )
- end
-
def self.set_imap_sync(topic_id)
IncomingEmail.joins(:post)
.where.not(imap_uid: nil)
.where(topic_id: topic_id, posts: { post_number: 1 })
.update_all(imap_sync: true)
end
+ private_class_method :set_imap_sync
+
+ def self.publish_topic_tracking_state(topic, group_id)
+ PrivateMessageTopicTrackingState.publish_group_archived(topic, group_id)
+ end
+ private_class_method :publish_topic_tracking_state
end
# == Schema Information
diff --git a/app/models/private_message_topic_tracking_state.rb b/app/models/private_message_topic_tracking_state.rb
new file mode 100644
index 00000000000..da5cf921fa5
--- /dev/null
+++ b/app/models/private_message_topic_tracking_state.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+# This class is used to mirror unread and new status for private messages between
+# server and client.
+#
+# On the server side, this class has two main responsibilities. The first is to
+# query the database for the initial state of a user's unread and new private
+# messages. The second is to publish message_bus messages to notify the client
+# of various topic events.
+#
+# On the client side, we have a `PrivateMessageTopicTrackingState` class as well
+# which will load the initial state into memory and subscribes to the relevant
+# message_bus messages. When a message is received, it modifies the in-memory
+# state based on the message type. The filtering for new and unread topics is
+# done on the client side based on the in-memory state in order to derive the
+# count of new and unread topics efficiently.
+class PrivateMessageTopicTrackingState
+ CHANNEL_PREFIX = "/private-message-topic-tracking-state"
+ NEW_MESSAGE_TYPE = "new_topic"
+ UNREAD_MESSAGE_TYPE = "unread"
+ ARCHIVE_MESSAGE_TYPE = "archive"
+ GROUP_ARCHIVE_MESSAGE_TYPE = "group_archive"
+
+ def self.report(user)
+ sql = new_and_unread_sql(user)
+
+ DB.query(
+ sql + "\n\n LIMIT :max_topics",
+ {
+ max_topics: TopicTrackingState::MAX_TOPICS,
+ min_new_topic_date: Time.at(SiteSetting.min_new_topics_time).to_datetime
+ }
+ )
+ end
+
+ def self.new_and_unread_sql(user)
+ sql = report_raw_sql(user, skip_unread: true)
+ sql << "\nUNION ALL\n\n"
+ sql << report_raw_sql(user, skip_new: true)
+ end
+
+ def self.report_raw_sql(user, skip_unread: false,
+ skip_new: false)
+
+ unread =
+ if skip_unread
+ "1=0"
+ else
+ TopicTrackingState.unread_filter_sql(staff: user.staff?)
+ end
+
+ new =
+ if skip_new
+ "1=0"
+ else
+ TopicTrackingState.new_filter_sql
+ end
+
+ sql = +<<~SQL
+ SELECT
+ DISTINCT topics.id AS topic_id,
+ u.id AS user_id,
+ last_read_post_number,
+ tu.notification_level,
+ #{TopicTrackingState.highest_post_number_column_select(user.staff?)},
+ ARRAY(SELECT group_id FROM topic_allowed_groups WHERE topic_allowed_groups.topic_id = topics.id) AS group_ids
+ FROM topics
+ JOIN users u on u.id = #{user.id.to_i}
+ JOIN user_stats AS us ON us.user_id = u.id
+ JOIN user_options AS uo ON uo.user_id = u.id
+ LEFT JOIN group_users gu ON gu.user_id = u.id
+ LEFT JOIN topic_allowed_groups tag ON tag.topic_id = topics.id AND tag.group_id = gu.group_id
+ LEFT JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = u.id
+ LEFT JOIN topic_allowed_users tau ON tau.topic_id = topics.id AND tau.user_id = u.id
+ #{skip_new ? "" : "LEFT JOIN dismissed_topic_users ON dismissed_topic_users.topic_id = topics.id AND dismissed_topic_users.user_id = #{user.id.to_i}"}
+ WHERE (tau.topic_id IS NOT NULL OR tag.topic_id IS NOT NULL) AND
+ #{skip_unread ? "" : "topics.updated_at >= LEAST(us.first_unread_pm_at, gu.first_unread_pm_at) AND"}
+ topics.archetype = 'private_message' AND
+ ((#{unread}) OR (#{new})) AND
+ topics.deleted_at IS NULL
+ SQL
+ end
+
+ def self.publish_unread(post)
+ return unless post.topic.private_message?
+
+ scope = TopicUser
+ .tracking(post.topic_id)
+ .includes(user: :user_stat)
+
+ allowed_group_ids = post.topic.allowed_groups.pluck(:id)
+
+ group_ids =
+ if post.post_type == Post.types[:whisper]
+ [Group::AUTO_GROUPS[:staff]]
+ else
+ allowed_group_ids
+ end
+
+ if group_ids.present?
+ scope = scope
+ .joins("INNER JOIN group_users gu ON gu.user_id = topic_users.user_id")
+ .where("gu.group_id IN (?)", group_ids)
+ end
+
+ scope
+ .select([:user_id, :last_read_post_number, :notification_level])
+ .each do |tu|
+
+ message = {
+ topic_id: post.topic_id,
+ message_type: UNREAD_MESSAGE_TYPE,
+ payload: {
+ last_read_post_number: tu.last_read_post_number,
+ highest_post_number: post.post_number,
+ notification_level: tu.notification_level,
+ group_ids: allowed_group_ids
+ }
+ }
+
+ MessageBus.publish(self.user_channel(tu.user_id), message.as_json,
+ user_ids: [tu.user_id]
+ )
+ end
+ end
+
+ def self.publish_new(topic)
+ return unless topic.private_message?
+
+ message = {
+ message_type: NEW_MESSAGE_TYPE,
+ topic_id: topic.id,
+ payload: {
+ last_read_post_number: nil,
+ highest_post_number: 1,
+ group_ids: topic.allowed_groups.pluck(:id)
+ }
+ }.as_json
+
+ topic.allowed_users.pluck(:id).each do |user_id|
+ MessageBus.publish(self.user_channel(user_id), message, user_ids: [user_id])
+ end
+
+ topic.allowed_groups.pluck(:id).each do |group_id|
+ MessageBus.publish(self.group_channel(group_id), message, group_ids: [group_id])
+ end
+ end
+
+ def self.publish_group_archived(topic, group_id)
+ return unless topic.private_message?
+
+ message = {
+ message_type: GROUP_ARCHIVE_MESSAGE_TYPE,
+ topic_id: topic.id,
+ payload: {
+ group_ids: [group_id]
+ }
+ }.as_json
+
+ MessageBus.publish(self.group_channel(group_id), message, group_ids: [group_id])
+ end
+
+ def self.publish_user_archived(topic, user_id)
+ return unless topic.private_message?
+
+ message = {
+ message_type: ARCHIVE_MESSAGE_TYPE,
+ topic_id: topic.id,
+ }.as_json
+
+ MessageBus.publish(self.user_channel(user_id), message, user_ids: [user_id])
+ end
+
+ def self.user_channel(user_id)
+ "#{CHANNEL_PREFIX}/user/#{user_id}"
+ end
+
+ def self.group_channel(group_id)
+ "#{CHANNEL_PREFIX}/group/#{group_id}"
+ end
+end
diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb
index 3051cbb8931..a5434399ff4 100644
--- a/app/models/topic_tracking_state.rb
+++ b/app/models/topic_tracking_state.rb
@@ -16,7 +16,6 @@
#
# See discourse/app/models/topic-tracking-state.js
class TopicTrackingState
-
include ActiveModel::SerializerSupport
UNREAD_MESSAGE_TYPE = "unread"
@@ -510,60 +509,6 @@ class TopicTrackingState
"#{staff ? "topics.highest_staff_post_number AS highest_post_number" : "topics.highest_post_number"}"
end
- def self.publish_private_message(topic, archive_user_id: nil,
- post: nil,
- group_archive: false)
-
- return unless topic.private_message?
- channels = {}
-
- allowed_user_ids = topic.allowed_users.pluck(:id)
-
- if post && allowed_user_ids.include?(post.user_id)
- channels["/private-messages/sent"] = [post.user_id]
- end
-
- if archive_user_id
- user_ids = [archive_user_id]
-
- [
- "/private-messages/archive",
- "/private-messages/inbox",
- "/private-messages/sent",
- ].each do |channel|
- channels[channel] = user_ids
- end
- end
-
- if channels.except("/private-messages/sent").blank?
- channels["/private-messages/inbox"] = allowed_user_ids
- end
-
- topic.allowed_groups.each do |group|
- group_user_ids = group.users.pluck(:id)
- next if group_user_ids.blank?
- group_channels = []
- channel_prefix = "/private-messages/group/#{group.name.downcase}"
- group_channels << "#{channel_prefix}/inbox"
- group_channels << "#{channel_prefix}/archive" if group_archive
- group_channels.each { |channel| channels[channel] = group_user_ids }
- end
-
- message = {
- topic_id: topic.id
- }
-
- channels.each do |channel, ids|
- if ids.present?
- MessageBus.publish(
- channel,
- message.as_json,
- user_ids: ids
- )
- end
- end
- end
-
def self.publish_read_indicator_on_write(topic_id, last_read_post_number, user_id)
topic = Topic.includes(:allowed_groups).select(:highest_post_number, :archetype, :id).find_by(id: topic_id)
diff --git a/app/models/user_archived_message.rb b/app/models/user_archived_message.rb
index 7a51c2c8601..f5ee006dfbe 100644
--- a/app/models/user_archived_message.rb
+++ b/app/models/user_archived_message.rb
@@ -36,13 +36,10 @@ class UserArchivedMessage < ActiveRecord::Base
end
end
- private
-
def self.publish_topic_tracking_state(topic, user_id)
- TopicTrackingState.publish_private_message(
- topic, archive_user_id: user_id
- )
+ PrivateMessageTopicTrackingState.publish_user_archived(topic, user_id)
end
+ private_class_method :publish_topic_tracking_state
end
# == Schema Information
diff --git a/app/serializers/private_message_topic_tracking_state_serializer.rb b/app/serializers/private_message_topic_tracking_state_serializer.rb
new file mode 100644
index 00000000000..d08b3b78d67
--- /dev/null
+++ b/app/serializers/private_message_topic_tracking_state_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class PrivateMessageTopicTrackingStateSerializer < ApplicationSerializer
+ attributes :topic_id,
+ :highest_post_number,
+ :last_read_post_number,
+ :notification_level,
+ :group_ids
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 685689e0aa9..fcbb059ecc5 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1184,7 +1184,13 @@ en:
latest: "Latest"
sent: "Sent"
unread: "Unread"
+ unread_with_count:
+ one: "Unread (%{count})"
+ other: "Unread (%{count})"
new: "New"
+ new_with_count:
+ one: "New (%{count})"
+ other: "New (%{count})"
archive: "Archive"
groups: "My Groups"
move_to_inbox: "Move to Inbox"
diff --git a/config/routes.rb b/config/routes.rb
index c7737d9fce0..8a237167203 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -509,6 +509,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username }
+ get "#{root_path}/:username/private-message-topic-tracking-state" => "users#private_message_topic_tracking_state", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/profile-hidden" => "users#profile_hidden"
put "#{root_path}/:username/feature-topic" => "users#feature_topic", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/clear-featured-topic" => "users#clear_featured_topic", constraints: { username: RouteFormat.username }
diff --git a/lib/post_jobs_enqueuer.rb b/lib/post_jobs_enqueuer.rb
index 4809fdc1e86..80d49c59751 100644
--- a/lib/post_jobs_enqueuer.rb
+++ b/lib/post_jobs_enqueuer.rb
@@ -20,11 +20,6 @@ class PostJobsEnqueuer
after_topic_create
make_visible
end
-
- if @topic.private_message?
- TopicTrackingState.publish_private_message(@topic, post: @post)
- TopicGroup.new_message_update(@topic.last_poster, @topic.id, @post.post_number)
- end
end
private
@@ -46,6 +41,7 @@ class PostJobsEnqueuer
end
def make_visible
+ return if @topic.private_message?
return unless SiteSetting.embed_unlisted?
return unless @post.post_number > 1
return if @topic.visible?
@@ -73,12 +69,18 @@ class PostJobsEnqueuer
@topic.posters = @topic.posters_summary
@topic.posts_count = 1
- TopicTrackingState.publish_new(@topic)
+ klass =
+ if @topic.private_message?
+ PrivateMessageTopicTrackingState
+ else
+ TopicTrackingState
+ end
+
+ klass.publish_new(@topic)
end
def skip_after_create?
@opts[:import_mode] ||
- @topic.private_message? ||
@post.post_type == Post.types[:moderator_action] ||
@post.post_type == Post.types[:small_action]
end
diff --git a/spec/models/group_archived_message_spec.rb b/spec/models/group_archived_message_spec.rb
new file mode 100644
index 00000000000..b186ea29394
--- /dev/null
+++ b/spec/models/group_archived_message_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe GroupArchivedMessage do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:user_2) { Fabricate(:user) }
+
+ fab!(:group) do
+ Fabricate(:group, messageable_level: Group::ALIAS_LEVELS[:everyone]).tap do |g|
+ g.add(user_2)
+ end
+ end
+
+ fab!(:group_message) do
+ create_post(
+ user: user,
+ target_group_names: [group.name],
+ archetype: Archetype.private_message
+ ).topic
+ end
+
+ describe '.move_to_inbox!' do
+ it 'should unarchive the topic correctly' do
+ described_class.archive!(group.id, group_message)
+
+ messages = MessageBus.track_publish(PrivateMessageTopicTrackingState.group_channel(group.id)) do
+ described_class.move_to_inbox!(group.id, group_message)
+ end
+
+ expect(messages.present?).to eq(true)
+
+ expect(GroupArchivedMessage.exists?(
+ topic: group_message,
+ group: group
+ )).to eq(false)
+ end
+ end
+
+ describe '.archive!' do
+ it 'should archive the topic correctly' do
+ messages = MessageBus.track_publish(PrivateMessageTopicTrackingState.group_channel(group.id)) do
+ described_class.archive!(group.id, group_message)
+ end
+
+ expect(GroupArchivedMessage.exists?(
+ topic: group_message,
+ group: group
+ )).to eq(true)
+
+ expect(messages.present?).to eq(true)
+ end
+ end
+end
diff --git a/spec/models/private_message_topic_tracking_state_spec.rb b/spec/models/private_message_topic_tracking_state_spec.rb
new file mode 100644
index 00000000000..164001bedd7
--- /dev/null
+++ b/spec/models/private_message_topic_tracking_state_spec.rb
@@ -0,0 +1,206 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe PrivateMessageTopicTrackingState do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:user_2) { Fabricate(:user) }
+
+ fab!(:group) do
+ Fabricate(:group, messageable_level: Group::ALIAS_LEVELS[:everyone]).tap do |g|
+ g.add(user_2)
+ end
+ end
+
+ fab!(:group_message) do
+ create_post(
+ user: user,
+ target_group_names: [group.name],
+ archetype: Archetype.private_message
+ ).topic
+ end
+
+ fab!(:private_message) do
+ create_post(
+ user: user,
+ target_usernames: [user_2.username],
+ archetype: Archetype.private_message
+ ).topic
+ end
+
+ fab!(:private_message_2) do
+ create_post(
+ user: user,
+ target_usernames: [Fabricate(:user).username],
+ archetype: Archetype.private_message
+ ).topic
+ end
+
+ describe '.report' do
+ it 'returns the right tracking state' do
+ TopicUser.find_by(user: user_2, topic: group_message).update!(
+ last_read_post_number: 1
+ )
+
+ expect(described_class.report(user_2).map(&:topic_id))
+ .to contain_exactly(private_message.id)
+
+ create_post(user: user, topic: group_message)
+
+ report = described_class.report(user_2)
+
+ expect(report.map(&:topic_id)).to contain_exactly(
+ group_message.id,
+ private_message.id
+ )
+
+ state = report.first
+
+ expect(state.topic_id).to eq(private_message.id)
+ expect(state.user_id).to eq(user_2.id)
+ expect(state.last_read_post_number).to eq(nil)
+ expect(state.notification_level).to eq(NotificationLevels.all[:watching])
+ expect(state.highest_post_number).to eq(1)
+ expect(state.group_ids).to eq([])
+
+ expect(report.last.group_ids).to contain_exactly(group.id)
+ end
+
+ it 'returns the right tracking state when topics contain whispers' do
+ TopicUser.find_by(user: user_2, topic: private_message).update!(
+ last_read_post_number: 1
+ )
+
+ create_post(
+ raw: "this is a test post",
+ topic: private_message,
+ post_type: Post.types[:whisper],
+ user: Fabricate(:admin)
+ )
+
+ expect(described_class.report(user_2).map(&:topic_id))
+ .to contain_exactly(group_message.id)
+
+ user_2.grant_admin!
+
+ tracking_state = described_class.report(user_2)
+
+ expect(tracking_state.map { |topic| [topic.topic_id, topic.highest_post_number] })
+ .to contain_exactly(
+ [group_message.id, 1],
+ [private_message.id, 2]
+ )
+ end
+
+ it 'returns the right tracking state when topics have been dismissed' do
+ DismissedTopicUser.create!(
+ user_id: user_2.id,
+ topic_id: group_message.id
+ )
+
+ expect(described_class.report(user_2).map(&:topic_id))
+ .to contain_exactly(private_message.id)
+ end
+ end
+
+ describe '.publish_new' do
+ it 'should publish the right message_bus message' do
+ messages = MessageBus.track_publish do
+ described_class.publish_new(private_message)
+ end
+
+ expect(messages.map(&:channel)).to contain_exactly(
+ described_class.user_channel(user.id),
+ described_class.user_channel(user_2.id)
+ )
+
+ data = messages.find do |message|
+ message.channel == described_class.user_channel(user.id)
+ end.data
+
+ expect(data['message_type']).to eq(described_class::NEW_MESSAGE_TYPE)
+ end
+
+ it 'should publish the right message_bus message for a group message' do
+ messages = MessageBus.track_publish do
+ described_class.publish_new(group_message)
+ end
+
+ expect(messages.map(&:channel)).to contain_exactly(
+ described_class.group_channel(group.id),
+ described_class.user_channel(user.id)
+ )
+
+ data = messages.find do |message|
+ message.channel == described_class.group_channel(group.id)
+ end.data
+
+ expect(data['message_type']).to eq(described_class::NEW_MESSAGE_TYPE)
+ expect(data['topic_id']).to eq(group_message.id)
+ expect(data['payload']['last_read_post_number']).to eq(nil)
+ expect(data['payload']['highest_post_number']).to eq(1)
+ expect(data['payload']['group_ids']).to eq([group.id])
+ end
+ end
+
+ describe '.publish_unread' do
+ it 'should publish the right message_bus message' do
+ messages = MessageBus.track_publish do
+ described_class.publish_unread(private_message.first_post)
+ end
+
+ expect(messages.map(&:channel)).to contain_exactly(
+ described_class.user_channel(user.id),
+ described_class.user_channel(user_2.id)
+ )
+
+ data = messages.find do |message|
+ message.channel == described_class.user_channel(user.id)
+ end.data
+
+ expect(data['message_type']).to eq(described_class::UNREAD_MESSAGE_TYPE)
+ expect(data['topic_id']).to eq(private_message.id)
+ expect(data['payload']['last_read_post_number']).to eq(1)
+ expect(data['payload']['highest_post_number']).to eq(1)
+ expect(data['payload']['notification_level'])
+ .to eq(NotificationLevels.all[:watching])
+ expect(data['payload']['group_ids']).to eq([])
+ end
+ end
+
+ describe '.publish_user_archived' do
+ it 'should publish the right message_bus message' do
+ message = MessageBus.track_publish(described_class.user_channel(user.id)) do
+ described_class.publish_user_archived(private_message, user.id)
+ end.first
+
+ data = message.data
+
+ expect(data['topic_id']).to eq(private_message.id)
+ expect(data['message_type']).to eq(described_class::ARCHIVE_MESSAGE_TYPE)
+ end
+ end
+
+ describe '.publish_group_archived' do
+ it 'should publish the right message_bus message' do
+ user_3 = Fabricate(:user)
+ group.add(user_3)
+
+ messages = MessageBus.track_publish do
+ described_class.publish_group_archived(group_message, group.id)
+ end
+
+ expect(messages.map(&:channel)).to contain_exactly(
+ described_class.group_channel(group.id)
+ )
+
+ data = messages.find do |message|
+ message.channel == described_class.group_channel(group.id)
+ end.data
+
+ expect(data['message_type']).to eq(described_class::GROUP_ARCHIVE_MESSAGE_TYPE)
+ expect(data['topic_id']).to eq(group_message.id)
+ expect(data['payload']['group_ids']).to contain_exactly(group.id)
+ end
+ end
+end
diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb
index ddcfc37c312..00472bb44c7 100644
--- a/spec/models/topic_tracking_state_spec.rb
+++ b/spec/models/topic_tracking_state_spec.rb
@@ -208,186 +208,6 @@ describe TopicTrackingState do
end
end
- describe '#publish_private_message' do
- fab!(:admin) { Fabricate(:admin) }
-
- describe 'normal topic' do
- it 'should publish the right message' do
- allowed_users = private_message_topic.allowed_users
-
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(private_message_topic)
- end
-
- expect(messages.count).to eq(1)
-
- message = messages.first
-
- expect(message.channel).to eq('/private-messages/inbox')
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to contain_exactly(*allowed_users.map(&:id))
- end
- end
-
- describe 'topic with groups' do
- fab!(:group1) { Fabricate(:group, users: [Fabricate(:user)]) }
- fab!(:group2) { Fabricate(:group, users: [Fabricate(:user), Fabricate(:user)]) }
-
- before do
- [group1, group2].each do |group|
- private_message_topic.allowed_groups << group
- end
- end
-
- it "should publish the right message" do
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(
- private_message_topic
- )
- end
-
- expect(messages.map(&:channel)).to contain_exactly(
- '/private-messages/inbox',
- "/private-messages/group/#{group1.name}/inbox",
- "/private-messages/group/#{group2.name}/inbox"
- )
-
- message = messages.find do |m|
- m.channel == '/private-messages/inbox'
- end
-
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq(private_message_topic.allowed_users.map(&:id))
-
- [group1, group2].each do |group|
- message = messages.find do |m|
- m.channel == "/private-messages/group/#{group.name}/inbox"
- end
-
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq(group.users.map(&:id))
- end
- end
-
- describe "archiving topic" do
- it "should publish the right message" do
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(
- private_message_topic,
- group_archive: true
- )
- end
-
- expect(messages.map(&:channel)).to contain_exactly(
- '/private-messages/inbox',
- "/private-messages/group/#{group1.name}/inbox",
- "/private-messages/group/#{group1.name}/archive",
- "/private-messages/group/#{group2.name}/inbox",
- "/private-messages/group/#{group2.name}/archive",
- )
-
- message = messages.find { |m| m.channel == '/private-messages/inbox' }
-
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq(private_message_topic.allowed_users.map(&:id))
-
- [group1, group2].each do |group|
- [
- "/private-messages/group/#{group.name}/inbox",
- "/private-messages/group/#{group.name}/archive"
- ].each do |channel|
- message = messages.find { |m| m.channel == channel }
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq(group.users.map(&:id))
- end
- end
- end
- end
- end
-
- describe 'topic with new post' do
- let(:user) { private_message_topic.allowed_users.last }
-
- let!(:post) do
- Fabricate(:post,
- topic: private_message_topic,
- user: user
- )
- end
-
- let!(:group) do
- group = Fabricate(:group, users: [Fabricate(:user)])
- private_message_topic.allowed_groups << group
- group
- end
-
- it 'should publish the right message' do
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(
- private_message_topic,
- post: post
- )
- end
-
- expected_channels = [
- '/private-messages/inbox',
- '/private-messages/sent',
- "/private-messages/group/#{group.name}/inbox"
- ]
-
- expect(messages.map(&:channel)).to contain_exactly(*expected_channels)
-
- expected_channels.zip([
- private_message_topic.allowed_users.map(&:id),
- [user.id],
- [group.users.first.id]
- ]).each do |channel, user_ids|
- message = messages.find { |m| m.channel == channel }
-
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq(user_ids)
- end
- end
- end
-
- describe 'archived topic' do
- it 'should publish the right message' do
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(
- private_message_topic,
- archive_user_id: private_message_post.user_id,
- )
- end
-
- expected_channels = [
- "/private-messages/archive",
- "/private-messages/inbox",
- "/private-messages/sent",
- ]
-
- expect(messages.map(&:channel)).to eq(expected_channels)
-
- expected_channels.each do |channel|
- message = messages.find { |m| m.channel = channel }
- expect(message.data["topic_id"]).to eq(private_message_topic.id)
- expect(message.user_ids).to eq([private_message_post.user_id])
- end
- end
- end
-
- describe 'for a regular topic' do
- it 'should not publish any message' do
- topic.allowed_users << Fabricate(:user)
-
- messages = MessageBus.track_publish do
- TopicTrackingState.publish_private_message(topic)
- end
-
- expect(messages).to eq([])
- end
- end
- end
-
describe '#publish_read_private_message' do
fab!(:group) { Fabricate(:group) }
let(:read_topic_key) { "/private-messages/unread-indicator/#{group_message.id}" }
diff --git a/spec/models/user_archived_message_spec.rb b/spec/models/user_archived_message_spec.rb
index d225b5bb360..dcfd7743e17 100644
--- a/spec/models/user_archived_message_spec.rb
+++ b/spec/models/user_archived_message_spec.rb
@@ -3,20 +3,51 @@
require 'rails_helper'
describe UserArchivedMessage do
- it 'Does not move archived muted messages back to inbox' do
- user = Fabricate(:admin)
- user2 = Fabricate(:admin)
+ fab!(:user) { Fabricate(:user) }
+ fab!(:user_2) { Fabricate(:user) }
- topic = create_post(user: user,
- skip_validations: true,
- target_usernames: [user2.username, user.username].join(","),
- archetype: Archetype.private_message).topic
-
- UserArchivedMessage.archive!(user.id, topic)
- expect(topic.message_archived?(user)).to eq(true)
-
- TopicUser.change(user.id, topic.id, notification_level: TopicUser.notification_levels[:muted])
- UserArchivedMessage.move_to_inbox!(user.id, topic)
- expect(topic.message_archived?(user)).to eq(true)
+ fab!(:private_message) do
+ create_post(
+ user: user,
+ skip_validations: true,
+ target_usernames: [user_2.username, user.username].join(","),
+ archetype: Archetype.private_message
+ ).topic
end
+
+ describe '.move_to_inbox!' do
+ it 'moves topic back to inbox correctly' do
+ UserArchivedMessage.archive!(user.id, private_message)
+
+ expect do
+ messages = MessageBus.track_publish(PrivateMessageTopicTrackingState.user_channel(user.id)) do
+ UserArchivedMessage.move_to_inbox!(user.id, private_message)
+ end
+
+ expect(messages.present?).to eq(true)
+ end.to change { private_message.message_archived?(user) }.from(true).to(false)
+ end
+
+ it 'does not move archived muted messages back to inbox' do
+ UserArchivedMessage.archive!(user.id, private_message)
+
+ expect(private_message.message_archived?(user)).to eq(true)
+
+ TopicUser.change(user.id, private_message.id, notification_level: TopicUser.notification_levels[:muted])
+ UserArchivedMessage.move_to_inbox!(user.id, private_message)
+
+ expect(private_message.message_archived?(user)).to eq(true)
+ end
+ end
+
+ describe '.archive' do
+ it 'archives message correctly' do
+ messages = MessageBus.track_publish(PrivateMessageTopicTrackingState.user_channel(user.id)) do
+ UserArchivedMessage.archive!(user.id, private_message)
+ end
+
+ expect(messages.present?).to eq(true)
+ end
+ end
+
end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index b22fe7349d3..abb36a7befe 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -5064,6 +5064,43 @@ describe UsersController do
end
end
+ describe "#private_message_topic_tracking_state" do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:user_2) { Fabricate(:user) }
+
+ fab!(:private_message) do
+ create_post(
+ user: user,
+ target_usernames: [user_2.username],
+ archetype: Archetype.private_message
+ ).topic
+ end
+
+ before do
+ sign_in(user_2)
+ end
+
+ it 'does not allow an unauthorized user to access the state of another user' do
+ get "/u/#{user.username}/private-message-topic-tracking-state.json"
+
+ expect(response.status).to eq(403)
+ end
+
+ it 'returns the right response' do
+ get "/u/#{user_2.username}/private-message-topic-tracking-state.json"
+
+ expect(response.status).to eq(200)
+
+ topic_state = response.parsed_body.first
+
+ expect(topic_state["topic_id"]).to eq(private_message.id)
+ expect(topic_state["highest_post_number"]).to eq(1)
+ expect(topic_state["last_read_post_number"]).to eq(nil)
+ expect(topic_state["notification_level"]).to eq(NotificationLevels.all[:watching])
+ expect(topic_state["group_ids"]).to eq([])
+ end
+ end
+
def create_second_factor_security_key
sign_in(user)
stub_secure_session_confirmed