DEV: Improving topic tracking state code (#12555)

The aim of this PR is to improve the topic tracking state JavaScript code and test coverage so further modifications can be made in plugins and in core. This is focused on making topic tracking state changes easier to respond to with callbacks, and changing it so all state modifications go through a single method instead of modifying `this.state` all over the place. I have also tried to improve documentation, make the code clearer and easier to follow, and make it clear what are public and private methods.

The changes I have made here should not break backwards compatibility, though there is no way to tell for sure if other plugin/theme authors are using tracking state methods that are essentially private methods. Any name changes made in the tracking-state.js code have been reflected in core.

----

We now have a `_trackedTopicLimit` in the tracking state. Previously, if a topic was neither new nor unread it was removed from the tracking state; now it is only removed if we are tracking more than `_trackedTopicLimit` topics (which is set to 4000). This is so plugins/themes adding topics with `TopicTrackingState.register_refine_method` can add topics to track that aren't necessarily new or unread, e.g. for totals counts.

Anywhere where we were doing `tracker.states["t" + data.topic_id] = newObject` has now been changed to flow through central `modifyState` and `modifyStateProp` methods. This is so state objects are not modified until they need to be (e.g. sometimes properties are set based on certain conditions) and also so we can run callback functions when the state is modified.

I added `onStateChange` and `onMessageIncrement` methods to register callbacks that are called when the state is changed and when the message count is incremented, respectively. This was done so we no longer need to do things like `@observes("trackingState.states")` in other Ember classes.

I split up giant functions like `sync` and `establishChannels` into smaller functions for readability and testability, and renamed many small functions to _functionName to designate them as private functions which not be called by consumers of `topicTrackingState`. Public functions are now all documented (well...at least ones that are not immediately obvious).

----

On the backend side, I have changed the MessageBus publish events for TopicTrackingState to send back tags and tag IDs for more channels, and done some extra code cleanup and refactoring. Plugins may override `TopicTrackingState.report` so I have made its footprint as small as possible and externalised the main parts of it into other methods.
This commit is contained in:
Martin Brennan 2021-04-28 09:54:45 +10:00 committed by GitHub
parent cdbdb04909
commit 45df579db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1380 additions and 458 deletions

View File

@ -21,8 +21,19 @@ const DiscoveryTopicsListComponent = Component.extend(UrlRefresh, LoadMore, {
}
},
@observes("topicTrackingState.states")
_updateTopics() {
@on("didInsertElement")
_monitorTrackingState() {
this.stateChangeCallbackId = this.topicTrackingState.onStateChange(
this._updateTrackingTopics.bind(this)
);
},
@on("willDestroyElement")
_removeTrackingStateChangeMonitor() {
this.topicTrackingState.offStateChange(this.stateChangeCallbackId);
},
_updateTrackingTopics() {
this.topicTrackingState.updateTopics(this.model.topics);
},

View File

@ -44,9 +44,7 @@ export default Mixin.create({
promise.then((result) => {
if (result && result.topic_ids) {
const tracker = this.topicTrackingState;
result.topic_ids.forEach((t) => tracker.removeTopic(t));
tracker.incrementMessageCount();
this.topicTrackingState.removeTopics(result.topic_ids);
}
this.send("closeModal");

View File

@ -1,27 +1,39 @@
import EmberObject, { get } from "@ember/object";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import Category from "discourse/models/category";
import { deepEqual, deepMerge } from "discourse-common/lib/object";
import DiscourseURL from "discourse/lib/url";
import { NotificationLevels } from "discourse/lib/notification-levels";
import PreloadStore from "discourse/lib/preload-store";
import User from "discourse/models/user";
import { deepEqual } from "discourse-common/lib/object";
import { isEmpty } from "@ember/utils";
function isNew(topic) {
function isNew(topic, currentUser) {
let createdInNewPeriod = true;
if (currentUser) {
createdInNewPeriod =
moment(topic.created_at) >=
moment(currentUser.treat_as_new_topic_start_date);
}
return (
topic.last_read_post_number === null &&
((topic.notification_level !== 0 && !topic.notification_level) ||
topic.notification_level >= NotificationLevels.TRACKING) &&
createdInNewPeriod &&
isUnseen(topic)
);
}
function isUnread(topic) {
let unreadNotTooOld = true;
if (topic.first_unread_at) {
unreadNotTooOld = moment(topic.updated_at) >= moment(topic.first_unread_at);
}
return (
topic.last_read_post_number !== null &&
topic.last_read_post_number < topic.highest_post_number &&
topic.notification_level >= NotificationLevels.TRACKING
topic.notification_level >= NotificationLevels.TRACKING &&
unreadNotTooOld
);
}
@ -49,104 +61,46 @@ const TopicTrackingState = EmberObject.extend({
this.unreadSequence = [];
this.newSequence = [];
this.states = {};
this.messageIncrementCallbacks = {};
this.stateChangeCallbacks = {};
this._trackedTopicLimit = 4000;
},
/**
* Subscribe to MessageBus channels which are used for publishing changes
* to the tracking state. Each message received will modify state for
* a particular topic.
*
* See app/models/topic_tracking_state.rb for the data payloads published
* to each of the channels.
*
* @method establishChannels
*/
establishChannels() {
const tracker = this;
const process = (data) => {
if (["muted", "unmuted"].includes(data.message_type)) {
tracker.trackMutedOrUnmutedTopic(data);
return;
}
tracker.pruneOldMutedAndUnmutedTopics();
if (tracker.isMutedTopic(data.topic_id)) {
return;
}
if (
this.siteSettings.mute_all_categories_by_default &&
!tracker.isUnmutedTopic(data.topic_id)
) {
return;
}
if (data.message_type === "delete") {
tracker.removeTopic(data.topic_id);
tracker.incrementMessageCount();
}
if (["new_topic", "latest"].includes(data.message_type)) {
const muted_category_ids = User.currentProp("muted_category_ids");
if (
muted_category_ids &&
muted_category_ids.includes(data.payload.category_id)
) {
return;
}
}
if (["new_topic", "latest"].includes(data.message_type)) {
const mutedTagIds = User.currentProp("muted_tag_ids");
if (
hasMutedTags(
data.payload.topic_tag_ids,
mutedTagIds,
this.siteSettings
)
) {
return;
}
}
if (data.message_type === "latest") {
tracker.notify(data);
}
if (data.message_type === "dismiss_new") {
tracker.dismissNewTopic(data);
}
if (["new_topic", "unread", "read"].includes(data.message_type)) {
tracker.notify(data);
const old = tracker.states["t" + data.topic_id];
if (!deepEqual(old, data.payload)) {
tracker.states["t" + data.topic_id] = data.payload;
tracker.notifyPropertyChange("states");
tracker.incrementMessageCount();
}
}
};
this.messageBus.subscribe("/new", process);
this.messageBus.subscribe("/latest", process);
this.messageBus.subscribe("/new", this._processChannelPayload.bind(this));
this.messageBus.subscribe(
"/latest",
this._processChannelPayload.bind(this)
);
if (this.currentUser) {
this.messageBus.subscribe(
"/unread/" + this.currentUser.get("id"),
process
this._processChannelPayload.bind(this)
);
}
this.messageBus.subscribe("/delete", (msg) => {
const old = tracker.states["t" + msg.topic_id];
if (old) {
old.deleted = true;
}
tracker.incrementMessageCount();
this.modifyStateProp(msg, "deleted", true);
this.incrementMessageCount();
});
this.messageBus.subscribe("/recover", (msg) => {
const old = tracker.states["t" + msg.topic_id];
if (old) {
delete old.deleted;
}
tracker.incrementMessageCount();
this.modifyStateProp(msg, "deleted", false);
this.incrementMessageCount();
});
this.messageBus.subscribe("/destroy", (msg) => {
tracker.incrementMessageCount();
this.incrementMessageCount();
const currentRoute = DiscourseURL.router.currentRoute.parent;
if (
currentRoute.name === "topic" &&
@ -181,17 +135,6 @@ const TopicTrackingState = EmberObject.extend({
this.currentUser && this.currentUser.set(key, topics);
},
dismissNewTopic(data) {
data.payload.topic_ids.forEach((k) => {
const topic = this.states[`t${k}`];
this.states[`t${k}`] = Object.assign({}, topic, {
is_seen: true,
});
});
this.notifyPropertyChange("states");
this.incrementMessageCount();
},
pruneOldMutedAndUnmutedTopics() {
const now = Date.now();
let mutedTopics = this.mutedTopics().filter(
@ -213,22 +156,50 @@ const TopicTrackingState = EmberObject.extend({
return !!this.unmutedTopics().findBy("topicId", topicId);
},
/**
* Updates the topic's last_read_post_number to the highestSeen post
* number, as long as the topic is being tracked.
*
* Calls onStateChange callbacks.
*
* @params {Number|String} topicId - The ID of the topic to set last_read_post_number for.
* @params {Number} highestSeen - The post number of the topic that should be
* used for last_read_post_number.
* @method updateSeen
*/
updateSeen(topicId, highestSeen) {
if (!topicId || !highestSeen) {
return;
}
const state = this.states["t" + topicId];
const state = this.findState(topicId);
if (!state) {
return;
}
if (
state &&
(!state.last_read_post_number ||
state.last_read_post_number < highestSeen)
!state.last_read_post_number ||
state.last_read_post_number < highestSeen
) {
state.last_read_post_number = highestSeen;
this.modifyStateProp(topicId, "last_read_post_number", highestSeen);
this.incrementMessageCount();
}
},
notify(data) {
/**
* Used to count incoming topics which will be displayed in a message
* at the top of the topic list, if hasIncoming is true (which is if
* incomingCount > 0).
*
* This will do nothing unless resetTracking or trackIncoming has been
* called; newIncoming will be null instead of an array. trackIncoming
* is called by various topic routes, as is resetTracking.
*
* @method notifyIncoming
* @param {Object} data - The data sent by TopicTrackingState to MessageBus
* which includes the message_type, payload of the topic,
* and the topic_id.
*/
notifyIncoming(data) {
if (!this.newIncoming) {
return;
}
@ -240,6 +211,9 @@ const TopicTrackingState = EmberObject.extend({
const filterCategory = this.filterCategory;
const categoryId = data.payload && data.payload.category_id;
// if we have a filter category currently and it is not the
// same as the topic category from the payload, then do nothing
// because it doesn't need to be counted as incoming
if (filterCategory && filterCategory.get("id") !== categoryId) {
const category = categoryId && Category.findById(categoryId);
if (
@ -250,46 +224,67 @@ const TopicTrackingState = EmberObject.extend({
}
}
// always count a new_topic as incoming
if (
["all", "latest", "new"].includes(filter) &&
data.message_type === "new_topic"
) {
this.addIncoming(data.topic_id);
this._addIncoming(data.topic_id);
}
// count an unread topic as incoming
if (["all", "unread"].includes(filter) && data.message_type === "unread") {
const old = this.states["t" + data.topic_id];
const old = this.findState(data);
// the highest post number is equal to last read post number here
// because the state has already been modified based on the /unread
// messageBus message
if (!old || old.highest_post_number === old.last_read_post_number) {
this.addIncoming(data.topic_id);
this._addIncoming(data.topic_id);
}
}
// always add incoming if looking at the latest list and a latest channel
// message comes through
if (filter === "latest" && data.message_type === "latest") {
this.addIncoming(data.topic_id);
this._addIncoming(data.topic_id);
}
// hasIncoming relies on this count
this.set("incomingCount", this.newIncoming.length);
},
addIncoming(topicId) {
if (this.newIncoming.indexOf(topicId) === -1) {
this.newIncoming.push(topicId);
}
},
/**
* Resets the number of incoming topics to 0 and flushes the new topics
* from the array. Without calling this or trackIncoming the notifyIncoming
* method will do nothing.
*
* @method resetTracking
*/
resetTracking() {
this.newIncoming = [];
this.set("incomingCount", 0);
},
// track how many new topics came for this filter
/**
* Track how many new topics came for the specified filter.
*
* Related/intertwined with notifyIncoming; the filter and filterCategory
* set here is used to determine whether or not to add incoming counts
* based on message types of incoming MessageBus messages (via establishChannels)
*
* @method trackIncoming
* @param {String} filter - Valid values are all, categories, and any topic list
* filters e.g. latest, unread, new. As well as this
* specific category and tag URLs like /tag/test/l/latest
* or c/cat/subcat/6/l/latest.
*/
trackIncoming(filter) {
this.newIncoming = [];
const split = filter.split("/");
const split = filter.split("/");
if (split.length >= 4) {
filter = split[split.length - 1];
// c/cat/subcat/6/l/latest
let category = Category.findSingleBySlug(
split.splice(1, split.length - 4).join("/")
);
@ -302,145 +297,126 @@ const TopicTrackingState = EmberObject.extend({
this.set("incomingCount", 0);
},
/**
* Used to determine whether toshow the message at the top of the topic list
* e.g. "see 1 new or updated topic"
*
* @method incomingCount
*/
@discourseComputed("incomingCount")
hasIncoming(incomingCount) {
return incomingCount && incomingCount > 0;
},
removeTopic(topic_id) {
delete this.states["t" + topic_id];
/**
* Removes the topic ID provided from the tracker state.
*
* Calls onStateChange callbacks.
*
* @param {Number|String} topicId - The ID of the topic to remove from state.
* @method removeTopic
*/
removeTopic(topicId) {
delete this.states[this._stateKey(topicId)];
this._afterStateChange();
},
// If we have a cached topic list, we can update it from our tracking information.
/**
* Removes multiple topics from the state at once, and increments
* the message count.
*
* Calls onStateChange callbacks.
*
* @param {Array} topicIds - The IDs of the topic to removes from state.
* @method removeTopics
*/
removeTopics(topicIds) {
topicIds.forEach((topicId) => this.removeTopic(topicId));
this.incrementMessageCount();
this._afterStateChange();
},
/**
* If we have a cached topic list, we can update it from our tracking information
* if the last_read_post_number or is_seen property does not match what the
* cached topic has.
*
* @method updateTopics
* @param {Array} topics - An array of Topic models.
*/
updateTopics(topics) {
if (isEmpty(topics)) {
return;
}
const states = this.states;
topics.forEach((t) => {
const state = states["t" + t.get("id")];
topics.forEach((topic) => {
const state = this.findState(topic.get("id"));
if (state) {
const lastRead = t.get("last_read_post_number");
const isSeen = t.get("is_seen");
if (
lastRead !== state.last_read_post_number ||
isSeen !== state.is_seen
) {
const postsCount = t.get("posts_count");
let newPosts = postsCount - state.highest_post_number,
unread = postsCount - state.last_read_post_number;
if (!state) {
return;
}
if (newPosts < 0) {
newPosts = 0;
}
if (!state.last_read_post_number) {
unread = 0;
}
if (unread < 0) {
unread = 0;
}
const lastRead = topic.get("last_read_post_number");
const isSeen = topic.get("is_seen");
t.setProperties({
highest_post_number: state.highest_post_number,
last_read_post_number: state.last_read_post_number,
new_posts: newPosts,
unread: unread,
is_seen: state.is_seen,
unseen: !state.last_read_post_number && isUnseen(state),
});
if (
lastRead !== state.last_read_post_number ||
isSeen !== state.is_seen
) {
const postsCount = topic.get("posts_count");
let newPosts = postsCount - state.highest_post_number,
unread = postsCount - state.last_read_post_number;
if (newPosts < 0) {
newPosts = 0;
}
if (!state.last_read_post_number) {
unread = 0;
}
if (unread < 0) {
unread = 0;
}
topic.setProperties({
highest_post_number: state.highest_post_number,
last_read_post_number: state.last_read_post_number,
new_posts: newPosts,
unread: unread,
is_seen: state.is_seen,
unseen: !state.last_read_post_number && isUnseen(state),
});
}
});
},
/**
* Uses the provided topic list to apply changes to the in-memory topic
* tracking state, remove state as required, and also compensate for missing
* in-memory state.
*
* Any state changes will make a callback to all state change callbacks defined
* via onStateChange and all message increment callbacks defined via onMessageIncrement
*
* @method sync
* @param {TopicList} list
* @param {String} filter - The filter used for the list e.g. new/unread
* @param {Object} queryParams - The query parameters for the list e.g. page
*/
sync(list, filter, queryParams) {
const tracker = this,
states = tracker.states;
if (!list || !list.topics) {
return;
}
// compensate for delayed "new" topics
// client side we know they are not new, server side we think they are
for (let i = list.topics.length - 1; i >= 0; i--) {
const state = states["t" + list.topics[i].id];
if (state && state.last_read_post_number > 0) {
if (filter === "new") {
list.topics.splice(i, 1);
} else {
list.topics[i].set("unseen", false);
list.topics[i].set("dont_sync", true);
}
}
}
// make sure any server-side state matches reality in the client side
this._fixDelayedServerState(list, filter);
list.topics.forEach(function (topic) {
const row = tracker.states["t" + topic.id] || {};
row.topic_id = topic.id;
row.notification_level = topic.notification_level;
// make sure all the state is up to date with what is accurate
// from the server
list.topics.forEach(this._syncStateFromListTopic.bind(this));
if (topic.unseen) {
row.last_read_post_number = null;
} else if (topic.unread || topic.new_posts) {
row.last_read_post_number =
topic.highest_post_number -
((topic.unread || 0) + (topic.new_posts || 0));
} else {
if (!topic.dont_sync) {
delete tracker.states["t" + topic.id];
}
return;
}
row.highest_post_number = topic.highest_post_number;
if (topic.category) {
row.category_id = topic.category.id;
}
if (topic.tags) {
row.tags = topic.tags;
}
tracker.states["t" + topic.id] = row;
});
// Correct missing states, safeguard in case message bus is corrupt
let shouldCompensate =
(filter === "new" || filter === "unread") && !list.more_topics_url;
if (shouldCompensate && queryParams) {
Object.keys(queryParams).forEach((k) => {
if (k !== "ascending" && k !== "order") {
shouldCompensate = false;
}
});
}
if (shouldCompensate) {
const ids = {};
list.topics.forEach((r) => (ids["t" + r.id] = true));
Object.keys(tracker.states).forEach((k) => {
// we are good if we are on the list
if (ids[k]) {
return;
}
const v = tracker.states[k];
if (filter === "unread" && isUnread(v)) {
// pretend read
v.last_read_post_number = v.highest_post_number;
}
if (filter === "new" && isNew(v)) {
// pretend not new
v.last_read_post_number = 1;
}
});
// correct missing states, safeguard in case message bus is corrupt
if (this._shouldCompensateState(list, filter, queryParams)) {
this._correctMissingState(list, filter);
}
this.incrementMessageCount();
@ -448,6 +424,27 @@ const TopicTrackingState = EmberObject.extend({
incrementMessageCount() {
this.incrementProperty("messageCount");
Object.values(this.messageIncrementCallbacks).forEach((cb) => cb());
},
_generateCallbackId() {
return Math.random().toString(12).substr(2, 9);
},
onMessageIncrement(cb) {
let callbackId = this._generateCallbackId();
this.messageIncrementCallbacks[callbackId] = cb;
return callbackId;
},
onStateChange(cb) {
let callbackId = this._generateCallbackId();
this.stateChangeCallbacks[callbackId] = cb;
return callbackId;
},
offStateChange(callbackId) {
delete this.stateChangeCallbacks[callbackId];
},
getSubCategoryIds(categoryId) {
@ -475,7 +472,7 @@ const TopicTrackingState = EmberObject.extend({
return Object.values(this.states).filter(
(topic) =>
filter(topic) &&
filter(topic, this.currentUser) &&
topic.archetype !== "private_message" &&
!topic.deleted &&
(!categoryId || subcategoryIds.has(topic.category_id)) &&
@ -499,40 +496,72 @@ const TopicTrackingState = EmberObject.extend({
);
},
forEachTracked(fn) {
Object.values(this.states).forEach((topic) => {
if (topic.archetype !== "private_message" && !topic.deleted) {
let newTopic = isNew(topic);
let unreadTopic = isUnread(topic);
if (newTopic || unreadTopic) {
fn(topic, newTopic, unreadTopic);
}
}
/**
* Calls the provided callback for each of the currenty tracked topics
* we have in state.
*
* @method forEachTracked
* @param {Function} fn - The callback function to call with the topic,
* newTopic which is a boolean result of isNew,
* and unreadTopic which is a boolean result of
* isUnread.
*/
forEachTracked(fn, opts = {}) {
this._trackedTopics(opts).forEach((trackedTopic) => {
fn(trackedTopic.topic, trackedTopic.newTopic, trackedTopic.unreadTopic);
});
},
countTags(tags) {
/**
* Using the array of tags provided, tallys up all topics via forEachTracked
* that we are tracking, separated into new/unread/total.
*
* Total is only counted if opts.includeTotal is specified.
*
* Output (from input ["pending", "bug"]):
*
* {
* pending: { unreadCount: 6, newCount: 1, totalCount: 10 },
* bug: { unreadCount: 0, newCount: 4, totalCount: 20 }
* }
*
* @method countTags
* @param opts - Valid options:
* * includeTotal - When true, a totalCount is incremented for
* all topics matching a tag.
*/
countTags(tags, opts = {}) {
let counts = {};
tags.forEach((tag) => {
counts[tag] = { unreadCount: 0, newCount: 0 };
});
this.forEachTracked((topic, newTopic, unreadTopic) => {
if (topic.tags) {
tags.forEach((tag) => {
if (topic.tags.indexOf(tag) > -1) {
if (unreadTopic) {
counts[tag].unreadCount++;
}
if (newTopic) {
counts[tag].newCount++;
}
}
});
if (opts.includeTotal) {
counts[tag].totalCount = 0;
}
});
this.forEachTracked(
(topic, newTopic, unreadTopic) => {
if (topic.tags && topic.tags.length > 0) {
tags.forEach((tag) => {
if (topic.tags.indexOf(tag) > -1) {
if (unreadTopic) {
counts[tag].unreadCount++;
}
if (newTopic) {
counts[tag].newCount++;
}
if (opts.includeTotal) {
counts[tag].totalCount++;
}
}
});
}
},
{ includeAll: opts.includeTotal }
);
return counts;
},
@ -577,21 +606,272 @@ const TopicTrackingState = EmberObject.extend({
},
loadStates(data) {
const states = this.states;
(data || []).forEach((topic) => {
this.modifyState(topic, topic);
});
},
// I am taking some shortcuts here to avoid 500 gets for a large list
if (data) {
data.forEach((topic) => {
states["t" + topic.topic_id] = topic;
modifyState(topic, data) {
this.states[this._stateKey(topic)] = data;
this._afterStateChange();
},
modifyStateProp(topic, prop, data) {
const state = this.states[this._stateKey(topic)];
if (state) {
state[prop] = data;
this._afterStateChange();
}
},
findState(topicOrId) {
return this.states[this._stateKey(topicOrId)];
},
/*
* private
*/
// fix delayed "new" topics by removing the now seen
// topic from the list (for the "new" list) or setting the topic
// to "seen" for other lists.
//
// client side we know they are not new, server side we think they are.
// this can happen if the list is cached or the update to the state
// for a particular seen topic has not yet reached the server.
_fixDelayedServerState(list, filter) {
for (let index = list.topics.length - 1; index >= 0; index--) {
const state = this.findState(list.topics[index].id);
if (state && state.last_read_post_number > 0) {
if (filter === "new") {
list.topics.splice(index, 1);
} else {
list.topics[index].set("unseen", false);
list.topics[index].set("prevent_sync", true);
}
}
}
},
// this updates the topic in the state to match the
// topic from the list (e.g. updates category, highest read post
// number, tags etc.)
_syncStateFromListTopic(topic) {
const state = this.findState(topic.id) || {};
// make a new copy so we aren't modifying the state object directly while
// we make changes
const newState = { ...state };
newState.topic_id = topic.id;
newState.notification_level = topic.notification_level;
// see ListableTopicSerializer for unread/unseen/new_posts and other
// topic property logic
if (topic.unseen) {
newState.last_read_post_number = null;
} else if (topic.unread || topic.new_posts) {
newState.last_read_post_number =
topic.highest_post_number -
((topic.unread || 0) + (topic.new_posts || 0));
} else {
// remove the topic if it is no longer unread/new (it has been seen)
// and if there are too many topics in memory
if (!topic.prevent_sync && this._maxStateSizeReached()) {
this.removeTopic(topic.id);
}
return;
}
newState.highest_post_number = topic.highest_post_number;
if (topic.category) {
newState.category_id = topic.category.id;
}
if (topic.tags) {
newState.tags = topic.tags;
}
this.modifyState(topic.id, newState);
},
// this stops sync of tracking state when list is filtered, in the past this
// would cause the tracking state to become inconsistent.
_shouldCompensateState(list, filter, queryParams) {
let shouldCompensate =
(filter === "new" || filter === "unread") && !list.more_topics_url;
if (shouldCompensate && queryParams) {
Object.keys(queryParams).forEach((k) => {
if (k !== "ascending" && k !== "order") {
shouldCompensate = false;
}
});
}
return shouldCompensate;
},
// any state that is not in the provided list must be updated
// based on the filter selected so we do not have any incorrect
// state in the list
_correctMissingState(list, filter) {
const ids = {};
list.topics.forEach((topic) => (ids[this._stateKey(topic.id)] = true));
Object.keys(this.states).forEach((topicKey) => {
// if the topic is already in the list then there is
// no compensation needed; we already have latest state
// from the backend
if (ids[topicKey]) {
return;
}
const newState = { ...this.findState(topicKey) };
if (filter === "unread" && isUnread(newState)) {
// pretend read. if unread, the highest_post_number will be greater
// than the last_read_post_number
newState.last_read_post_number = newState.highest_post_number;
}
if (filter === "new" && isNew(newState, this.currentUser)) {
// pretend not new. if the topic is new, then last_read_post_number
// will be null.
newState.last_read_post_number = 1;
}
this.modifyState(topicKey, newState);
});
},
// processes the data sent via messageBus, called by establishChannels
_processChannelPayload(data) {
if (["muted", "unmuted"].includes(data.message_type)) {
this.trackMutedOrUnmutedTopic(data);
return;
}
this.pruneOldMutedAndUnmutedTopics();
if (this.isMutedTopic(data.topic_id)) {
return;
}
if (
this.siteSettings.mute_all_categories_by_default &&
!this.isUnmutedTopic(data.topic_id)
) {
return;
}
if (["new_topic", "latest"].includes(data.message_type)) {
const muted_category_ids = User.currentProp("muted_category_ids");
if (
muted_category_ids &&
muted_category_ids.includes(data.payload.category_id)
) {
return;
}
}
if (["new_topic", "latest"].includes(data.message_type)) {
const mutedTagIds = User.currentProp("muted_tag_ids");
if (
hasMutedTags(data.payload.topic_tag_ids, mutedTagIds, this.siteSettings)
) {
return;
}
}
const old = this.findState(data);
if (data.message_type === "latest") {
this.notifyIncoming(data);
if ((old && old.tags) !== data.payload.tags) {
this.modifyStateProp(data, "tags", data.payload.tags);
this.incrementMessageCount();
}
}
if (data.message_type === "dismiss_new") {
this._dismissNewTopics(data.payload.topic_ids);
}
if (["new_topic", "unread", "read"].includes(data.message_type)) {
this.notifyIncoming(data);
if (!deepEqual(old, data.payload)) {
if (data.message_type === "read") {
let mergeData = {};
// we have to do this because the "read" event does not
// include tags; we don't want them to be overridden
if (old) {
mergeData = {
tags: old.tags,
topic_tag_ids: old.topic_tag_ids,
};
}
this.modifyState(data, deepMerge(data.payload, mergeData));
} else {
this.modifyState(data, data.payload);
}
this.incrementMessageCount();
}
}
},
_dismissNewTopics(topicIds) {
topicIds.forEach((topicId) => {
this.modifyStateProp(topicId, "is_seen", true);
});
this.incrementMessageCount();
},
_addIncoming(topicId) {
if (this.newIncoming.indexOf(topicId) === -1) {
this.newIncoming.push(topicId);
}
},
_trackedTopics(opts = {}) {
return Object.values(this.states)
.map((topic) => {
if (topic.archetype !== "private_message" && !topic.deleted) {
let newTopic = isNew(topic, this.currentUser);
let unreadTopic = isUnread(topic);
if (newTopic || unreadTopic || opts.includeAll) {
return { topic, newTopic, unreadTopic };
}
}
})
.compact();
},
_stateKey(topicOrId) {
if (typeof topicOrId === "number") {
return `t${topicOrId}`;
} else if (typeof topicOrId === "string" && topicOrId.indexOf("t") > -1) {
return topicOrId;
} else {
return `t${topicOrId.topic_id}`;
}
},
_afterStateChange() {
this.notifyPropertyChange("states");
Object.values(this.stateChangeCallbacks).forEach((cb) => cb());
},
_maxStateSizeReached() {
return Object.keys(this.states).length >= this._trackedTopicLimit;
},
});
export function startTracking(tracking) {
const data = PreloadStore.get("topicTrackingStates");
tracking.loadStates(data);
tracking.initialStatesLength = data && data.length;
tracking.establishChannels();
PreloadStore.remove("topicTrackingStates");
}

View File

@ -98,6 +98,7 @@ const ORIGINAL_SETTINGS = {
unicode_usernames: false,
secure_media: false,
external_emoji_url: "",
remove_muted_tags_from_latest: "always",
};
let siteSettings = Object.assign({}, ORIGINAL_SETTINGS);

View File

@ -40,7 +40,7 @@ module("Unit | Model | nav-item", function (hooks) {
assert.equal(navItem.get("count"), 0, "it has no count by default");
const tracker = navItem.get("topicTrackingState");
tracker.states["t1"] = { topic_id: 1, last_read_post_number: null };
tracker.modifyState("t1", { topic_id: 1, last_read_post_number: null });
tracker.incrementMessageCount();
assert.equal(

View File

@ -1,12 +1,20 @@
import { module, test } from "qunit";
import { test } from "qunit";
import DiscourseURL from "discourse/lib/url";
import { getProperties } from "@ember/object";
import Category from "discourse/models/category";
import MessageBus from "message-bus-client";
import {
discourseModule,
publishToMessageBus,
} from "discourse/tests/helpers/qunit-helpers";
import { NotificationLevels } from "discourse/lib/notification-levels";
import TopicTrackingState from "discourse/models/topic-tracking-state";
import User from "discourse/models/user";
import Topic from "discourse/models/topic";
import createStore from "discourse/tests/helpers/create-store";
import sinon from "sinon";
module("Unit | Model | topic-tracking-state", function (hooks) {
discourseModule("Unit | Model | topic-tracking-state", function (hooks) {
hooks.beforeEach(function () {
this.clock = sinon.useFakeTimers(new Date(2012, 11, 31, 12, 0).getTime());
});
@ -16,18 +24,18 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
});
test("tag counts", function (assert) {
const state = TopicTrackingState.create();
const trackingState = TopicTrackingState.create();
state.loadStates([
trackingState.loadStates([
{
topic_id: 1,
last_read_post_number: null,
tags: ["foo", "new"],
tags: ["foo", "baz"],
},
{
topic_id: 2,
last_read_post_number: null,
tags: ["new"],
tags: ["baz"],
},
{
topic_id: 3,
@ -38,14 +46,14 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
topic_id: 4,
last_read_post_number: 1,
highest_post_number: 7,
tags: ["unread"],
tags: ["pending"],
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 5,
last_read_post_number: 1,
highest_post_number: 7,
tags: ["bar", "unread"],
tags: ["bar", "pending"],
notification_level: NotificationLevels.TRACKING,
},
{
@ -57,18 +65,92 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
},
]);
const states = state.countTags(["new", "unread"]);
const tagCounts = trackingState.countTags(["baz", "pending"]);
assert.equal(states["new"].newCount, 2, "new counts");
assert.equal(states["new"].unreadCount, 0, "new counts");
assert.equal(states["unread"].unreadCount, 2, "unread counts");
assert.equal(states["unread"].newCount, 0, "unread counts");
assert.equal(tagCounts["baz"].newCount, 2, "baz counts");
assert.equal(tagCounts["baz"].unreadCount, 0, "baz counts");
assert.equal(tagCounts["pending"].unreadCount, 2, "pending counts");
assert.equal(tagCounts["pending"].newCount, 0, "pending counts");
});
test("tag counts - with total", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([
{
topic_id: 1,
last_read_post_number: null,
tags: ["foo", "baz"],
},
{
topic_id: 2,
last_read_post_number: null,
tags: ["baz"],
},
{
topic_id: 3,
last_read_post_number: null,
tags: ["random"],
},
{
topic_id: 4,
last_read_post_number: 1,
highest_post_number: 7,
tags: ["pending"],
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 5,
last_read_post_number: 1,
highest_post_number: 7,
tags: ["bar", "pending"],
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 6,
last_read_post_number: 1,
highest_post_number: 7,
tags: null,
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 7,
last_read_post_number: 7,
highest_post_number: 7,
tags: ["foo", "baz"],
},
{
topic_id: 8,
last_read_post_number: 4,
highest_post_number: 4,
tags: ["pending"],
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 9,
last_read_post_number: 88,
highest_post_number: 88,
tags: ["pending"],
notification_level: NotificationLevels.TRACKING,
},
]);
const states = trackingState.countTags(["baz", "pending"], {
includeTotal: true,
});
assert.equal(states["baz"].newCount, 2, "baz counts");
assert.equal(states["baz"].unreadCount, 0, "baz counts");
assert.equal(states["baz"].totalCount, 3, "baz counts");
assert.equal(states["pending"].unreadCount, 2, "pending counts");
assert.equal(states["pending"].newCount, 0, "pending counts");
assert.equal(states["pending"].totalCount, 4, "pending counts");
});
test("forEachTracked", function (assert) {
const state = TopicTrackingState.create();
const trackingState = TopicTrackingState.create();
state.loadStates([
trackingState.loadStates([
{
topic_id: 1,
last_read_post_number: null,
@ -114,7 +196,7 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
sevenUnread = 0,
sevenNew = 0;
state.forEachTracked((topic, isNew, isUnread) => {
trackingState.forEachTracked((topic, isNew, isUnread) => {
if (topic.category_id === 7) {
if (isNew) {
sevenNew += 1;
@ -140,11 +222,11 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
assert.equal(sevenUnread, 2, "seven unread");
});
test("sync", function (assert) {
const state = TopicTrackingState.create();
state.states["t111"] = { last_read_post_number: null };
test("sync - delayed new topics for backend list are removed", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([{ last_read_post_number: null, topic_id: 111 }]);
state.updateSeen(111, 7);
trackingState.updateSeen(111, 7);
const list = {
topics: [
{
@ -156,7 +238,7 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
],
};
state.sync(list, "new");
trackingState.sync(list, "new");
assert.equal(
list.topics.length,
0,
@ -164,6 +246,496 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
);
});
test("sync - delayed unread topics for backend list are marked seen", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([{ last_read_post_number: null, topic_id: 111 }]);
trackingState.updateSeen(111, 7);
const list = {
topics: [
Topic.create({
highest_post_number: null,
id: 111,
unread: 10,
new_posts: 10,
unseen: true,
prevent_sync: false,
}),
],
};
trackingState.sync(list, "unread");
assert.equal(
list.topics[0].unseen,
false,
"expect unread topic to be marked as seen"
);
assert.equal(
list.topics[0].prevent_sync,
true,
"expect unread topic to be marked as prevent_sync"
);
});
test("sync - remove topic from state for performance if it is seen and has no unread or new posts and there are too many tracked topics in memory", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([{ topic_id: 111 }, { topic_id: 222 }]);
trackingState.set("_trackedTopicLimit", 1);
const list = {
topics: [
Topic.create({
id: 111,
unseen: false,
seen: true,
unread: 0,
new_posts: 0,
prevent_sync: false,
}),
],
};
trackingState.sync(list, "unread");
assert.notOk(
trackingState.states.hasOwnProperty("t111"),
"expect state for topic 111 to be deleted"
);
trackingState.loadStates([{ topic_id: 111 }, { topic_id: 222 }]);
trackingState.set("_trackedTopicLimit", 5);
trackingState.sync(list, "unread");
assert.ok(
trackingState.states.hasOwnProperty("t111"),
"expect state for topic 111 not to be deleted"
);
});
test("sync - updates state to match list topic for unseen and unread/new topics", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([
{ topic_id: 111, last_read_post_number: 0 },
{ topic_id: 222, last_read_post_number: 1 },
]);
const list = {
topics: [
Topic.create({
id: 111,
unseen: true,
seen: false,
unread: 0,
new_posts: 0,
highest_post_number: 20,
category: {
id: 123,
name: "test category",
},
tags: ["pending"],
}),
Topic.create({
id: 222,
unseen: false,
seen: true,
unread: 3,
new_posts: 0,
highest_post_number: 20,
}),
],
};
trackingState.sync(list, "unread");
let state111 = trackingState.findState(111);
let state222 = trackingState.findState(222);
assert.equal(
state111.last_read_post_number,
null,
"unseen topics get last_read_post_number reset to null"
);
assert.propEqual(
getProperties(state111, "highest_post_number", "tags", "category_id"),
{ highest_post_number: 20, tags: ["pending"], category_id: 123 },
"highest_post_number, category, and tags are set for a topic"
);
assert.equal(
state222.last_read_post_number,
17,
"last_read_post_number is highest_post_number - (unread + new)"
);
});
test("sync - states missing from the topic list are updated based on the selected filter", function (assert) {
const trackingState = TopicTrackingState.create();
trackingState.loadStates([
{
topic_id: 111,
last_read_post_number: 4,
highest_post_number: 5,
notification_level: NotificationLevels.TRACKING,
},
{
topic_id: 222,
last_read_post_number: null,
seen: false,
notification_level: NotificationLevels.TRACKING,
},
]);
const list = {
topics: [],
};
trackingState.sync(list, "unread");
assert.equal(
trackingState.findState(111).last_read_post_number,
5,
"last_read_post_number set to highest post number to pretend read"
);
trackingState.sync(list, "new");
assert.equal(
trackingState.findState(222).last_read_post_number,
1,
"last_read_post_number set to 1 to pretend not new"
);
});
discourseModule(
"establishChannels - /unread/:userId MessageBus channel payloads processed",
function (unreadHooks) {
let trackingState;
let unreadTopicPayload = {
topic_id: 111,
message_type: "unread",
payload: {
topic_id: 111,
category_id: 123,
topic_tag_ids: [44],
tags: ["pending"],
last_read_post_number: 4,
highest_post_number: 10,
created_at: "2012-11-31 12:00:00 UTC",
archetype: "regular",
notification_level: NotificationLevels.TRACKING,
},
};
let currentUser;
unreadHooks.beforeEach(function () {
currentUser = User.create({
username: "chuck",
});
User.resetCurrent(currentUser);
trackingState = TopicTrackingState.create({
messageBus: MessageBus,
currentUser,
siteSettings: this.siteSettings,
});
trackingState.establishChannels();
trackingState.loadStates([
{
topic_id: 111,
last_read_post_number: 4,
highest_post_number: 4,
notification_level: NotificationLevels.TRACKING,
},
]);
});
test("message count is incremented and callback is called", function (assert) {
let messageIncrementCalled = false;
trackingState.onMessageIncrement(() => {
messageIncrementCalled = true;
});
publishToMessageBus(`/unread/${currentUser.id}`, unreadTopicPayload);
assert.equal(
trackingState.messageCount,
1,
"message count incremented"
);
assert.equal(
messageIncrementCalled,
true,
"message increment callback called"
);
});
test("state is modified and callback is called", function (assert) {
let stateCallbackCalled = false;
trackingState.onStateChange(() => {
stateCallbackCalled = true;
});
publishToMessageBus(`/unread/${currentUser.id}`, unreadTopicPayload);
assert.deepEqual(
trackingState.findState(111),
{
topic_id: 111,
category_id: 123,
topic_tag_ids: [44],
tags: ["pending"],
last_read_post_number: 4,
highest_post_number: 10,
notification_level: NotificationLevels.TRACKING,
created_at: "2012-11-31 12:00:00 UTC",
archetype: "regular",
},
"topic state updated"
);
assert.equal(stateCallbackCalled, true, "state change callback called");
});
test("adds incoming so it is counted in topic lists", function (assert) {
trackingState.trackIncoming("all");
publishToMessageBus(`/unread/${currentUser.id}`, unreadTopicPayload);
assert.deepEqual(
trackingState.newIncoming,
[111],
"unread topic is incoming"
);
assert.equal(
trackingState.incomingCount,
1,
"incoming count is increased"
);
});
test("dismisses new topic", function (assert) {
trackingState.loadStates([
{
last_read_post_number: null,
topic_id: 112,
notification_level: NotificationLevels.TRACKING,
category_id: 1,
is_seen: false,
tags: ["foo"],
},
]);
publishToMessageBus(`/unread/${currentUser.id}`, {
message_type: "dismiss_new",
payload: { topic_ids: [112] },
});
assert.equal(trackingState.findState(112).is_seen, true);
});
test("marks a topic as read", function (assert) {
trackingState.loadStates([
{
last_read_post_number: null,
topic_id: 112,
notification_level: NotificationLevels.TRACKING,
category_id: 1,
is_seen: false,
tags: ["foo"],
},
]);
publishToMessageBus(`/unread/${currentUser.id}`, {
message_type: "read",
topic_id: 112,
payload: {
topic_id: 112,
last_read_post_number: 4,
highest_post_number: 4,
notification_level: NotificationLevels.TRACKING,
},
});
assert.propEqual(
getProperties(
trackingState.findState(112),
"highest_post_number",
"last_read_post_number"
),
{ highest_post_number: 4, last_read_post_number: 4 },
"highest_post_number and last_read_post_number are set for a topic"
);
assert.deepEqual(
trackingState.findState(112).tags,
["foo"],
"tags are not accidentally cleared"
);
});
}
);
discourseModule(
"establishChannels - /new MessageBus channel payloads processed",
function (establishChannelsHooks) {
let trackingState;
let newTopicPayload = {
topic_id: 222,
message_type: "new_topic",
payload: {
topic_id: 222,
category_id: 123,
topic_tag_ids: [44],
tags: ["pending"],
last_read_post_number: null,
highest_post_number: 1,
created_at: "2012-11-31 12:00:00 UTC",
archetype: "regular",
},
};
let currentUser;
establishChannelsHooks.beforeEach(function () {
currentUser = User.create({
username: "chuck",
});
User.resetCurrent(currentUser);
trackingState = TopicTrackingState.create({
messageBus: MessageBus,
currentUser,
siteSettings: this.siteSettings,
});
trackingState.establishChannels();
});
test("topics in muted categories do not get added to the state", function (assert) {
trackingState.currentUser.set("muted_category_ids", [123]);
publishToMessageBus("/new", newTopicPayload);
assert.equal(
trackingState.findState(222),
null,
"the new topic is not in the state"
);
});
test("topics in muted tags do not get added to the state", function (assert) {
trackingState.currentUser.set("muted_tag_ids", [44]);
publishToMessageBus("/new", newTopicPayload);
assert.equal(
trackingState.findState(222),
null,
"the new topic is not in the state"
);
});
test("message count is incremented and callback is called", function (assert) {
let messageIncrementCalled = false;
trackingState.onMessageIncrement(() => {
messageIncrementCalled = true;
});
publishToMessageBus("/new", newTopicPayload);
assert.equal(
trackingState.messageCount,
1,
"message count incremented"
);
assert.equal(
messageIncrementCalled,
true,
"message increment callback called"
);
});
test("state is modified and callback is called", function (assert) {
let stateCallbackCalled = false;
trackingState.onStateChange(() => {
stateCallbackCalled = true;
});
publishToMessageBus("/new", newTopicPayload);
assert.deepEqual(
trackingState.findState(222),
{
topic_id: 222,
category_id: 123,
topic_tag_ids: [44],
tags: ["pending"],
last_read_post_number: null,
highest_post_number: 1,
created_at: "2012-11-31 12:00:00 UTC",
archetype: "regular",
},
"new topic loaded into state"
);
assert.equal(stateCallbackCalled, true, "state change callback called");
});
test("adds incoming so it is counted in topic lists", function (assert) {
trackingState.trackIncoming("all");
publishToMessageBus("/new", newTopicPayload);
assert.deepEqual(
trackingState.newIncoming,
[222],
"new topic is incoming"
);
assert.equal(
trackingState.incomingCount,
1,
"incoming count is increased"
);
});
}
);
test("establishChannels - /delete MessageBus channel payloads processed", function (assert) {
const trackingState = TopicTrackingState.create({ messageBus: MessageBus });
trackingState.establishChannels();
trackingState.loadStates([
{
topic_id: 111,
deleted: false,
},
]);
publishToMessageBus("/delete", { topic_id: 111 });
assert.equal(
trackingState.findState(111).deleted,
true,
"marks the topic as deleted"
);
assert.equal(trackingState.messageCount, 1, "increments message count");
});
test("establishChannels - /recover MessageBus channel payloads processed", function (assert) {
const trackingState = TopicTrackingState.create({ messageBus: MessageBus });
trackingState.establishChannels();
trackingState.loadStates([
{
topic_id: 111,
deleted: true,
},
]);
publishToMessageBus("/recover", { topic_id: 111 });
assert.equal(
trackingState.findState(111).deleted,
false,
"marks the topic as not deleted"
);
assert.equal(trackingState.messageCount, 1, "increments message count");
});
test("establishChannels - /destroy MessageBus channel payloads processed", function (assert) {
sinon.stub(DiscourseURL, "router").value({
currentRoute: { parent: { name: "topic", params: { id: 111 } } },
});
sinon.stub(DiscourseURL, "redirectTo");
const trackingState = TopicTrackingState.create({ messageBus: MessageBus });
trackingState.establishChannels();
trackingState.loadStates([
{
topic_id: 111,
deleted: false,
},
]);
publishToMessageBus("/destroy", { topic_id: 111 });
assert.equal(trackingState.messageCount, 1, "increments message count");
assert.ok(
DiscourseURL.redirectTo.calledWith("/"),
"redirect to / because topic is destroyed"
);
});
test("subscribe to category", function (assert) {
const store = createStore();
const darth = store.createRecord("category", { id: 1, slug: "darth" }),
@ -176,53 +748,53 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
sinon.stub(Category, "list").returns(categoryList);
const state = TopicTrackingState.create();
const trackingState = TopicTrackingState.create();
state.trackIncoming("c/darth/1/l/latest");
trackingState.trackIncoming("c/darth/1/l/latest");
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 1,
payload: { category_id: 2, topic_id: 1 },
});
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 2,
payload: { category_id: 3, topic_id: 2 },
});
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 3,
payload: { category_id: 1, topic_id: 3 },
});
assert.equal(
state.get("incomingCount"),
trackingState.get("incomingCount"),
2,
"expect to properly track incoming for category"
);
state.resetTracking();
state.trackIncoming("c/darth/luke/2/l/latest");
trackingState.resetTracking();
trackingState.trackIncoming("c/darth/luke/2/l/latest");
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 1,
payload: { category_id: 2, topic_id: 1 },
});
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 2,
payload: { category_id: 3, topic_id: 2 },
});
state.notify({
trackingState.notifyIncoming({
message_type: "new_topic",
topic_id: 3,
payload: { category_id: 1, topic_id: 3 },
});
assert.equal(
state.get("incomingCount"),
trackingState.get("incomingCount"),
1,
"expect to properly track incoming for subcategory"
);
@ -243,10 +815,10 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
});
sinon.stub(Category, "list").returns([foo, bar, baz]);
const state = TopicTrackingState.create();
assert.deepEqual(Array.from(state.getSubCategoryIds(1)), [1, 2, 3]);
assert.deepEqual(Array.from(state.getSubCategoryIds(2)), [2, 3]);
assert.deepEqual(Array.from(state.getSubCategoryIds(3)), [3]);
const trackingState = TopicTrackingState.create();
assert.deepEqual(Array.from(trackingState.getSubCategoryIds(1)), [1, 2, 3]);
assert.deepEqual(Array.from(trackingState.getSubCategoryIds(2)), [2, 3]);
assert.deepEqual(Array.from(trackingState.getSubCategoryIds(3)), [3]);
});
test("countNew", function (assert) {
@ -276,26 +848,26 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
muted_category_ids: [4],
});
const state = TopicTrackingState.create({ currentUser });
const trackingState = TopicTrackingState.create({ currentUser });
assert.equal(state.countNew(1), 0);
assert.equal(state.countNew(2), 0);
assert.equal(state.countNew(3), 0);
assert.equal(trackingState.countNew(1), 0);
assert.equal(trackingState.countNew(2), 0);
assert.equal(trackingState.countNew(3), 0);
state.states["t112"] = {
trackingState.states["t112"] = {
last_read_post_number: null,
id: 112,
notification_level: NotificationLevels.TRACKING,
category_id: 2,
};
assert.equal(state.countNew(1), 1);
assert.equal(state.countNew(1, undefined, true), 0);
assert.equal(state.countNew(1, "missing-tag"), 0);
assert.equal(state.countNew(2), 1);
assert.equal(state.countNew(3), 0);
assert.equal(trackingState.countNew(1), 1);
assert.equal(trackingState.countNew(1, undefined, true), 0);
assert.equal(trackingState.countNew(1, "missing-tag"), 0);
assert.equal(trackingState.countNew(2), 1);
assert.equal(trackingState.countNew(3), 0);
state.states["t113"] = {
trackingState.states["t113"] = {
last_read_post_number: null,
id: 113,
notification_level: NotificationLevels.TRACKING,
@ -303,52 +875,29 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
tags: ["amazing"],
};
assert.equal(state.countNew(1), 2);
assert.equal(state.countNew(2), 2);
assert.equal(state.countNew(3), 1);
assert.equal(state.countNew(3, "amazing"), 1);
assert.equal(state.countNew(3, "missing"), 0);
assert.equal(trackingState.countNew(1), 2);
assert.equal(trackingState.countNew(2), 2);
assert.equal(trackingState.countNew(3), 1);
assert.equal(trackingState.countNew(3, "amazing"), 1);
assert.equal(trackingState.countNew(3, "missing"), 0);
state.states["t111"] = {
trackingState.states["t111"] = {
last_read_post_number: null,
id: 111,
notification_level: NotificationLevels.TRACKING,
category_id: 1,
};
assert.equal(state.countNew(1), 3);
assert.equal(state.countNew(2), 2);
assert.equal(state.countNew(3), 1);
assert.equal(trackingState.countNew(1), 3);
assert.equal(trackingState.countNew(2), 2);
assert.equal(trackingState.countNew(3), 1);
state.states["t115"] = {
trackingState.states["t115"] = {
last_read_post_number: null,
id: 115,
category_id: 4,
};
assert.equal(state.countNew(4), 0);
});
test("dismissNew", function (assert) {
let currentUser = User.create({
username: "chuck",
});
const state = TopicTrackingState.create({ currentUser });
state.states["t112"] = {
last_read_post_number: null,
id: 112,
notification_level: NotificationLevels.TRACKING,
category_id: 1,
is_seen: false,
tags: ["foo"],
};
state.dismissNewTopic({
message_type: "dismiss_new",
payload: { topic_ids: [112] },
});
assert.equal(state.states["t112"].is_seen, true);
assert.equal(trackingState.countNew(4), 0);
});
test("mute and unmute topic", function (assert) {
@ -357,21 +906,27 @@ module("Unit | Model | topic-tracking-state", function (hooks) {
muted_category_ids: [],
});
const state = TopicTrackingState.create({ currentUser });
const trackingState = TopicTrackingState.create({ currentUser });
state.trackMutedOrUnmutedTopic({ topic_id: 1, message_type: "muted" });
trackingState.trackMutedOrUnmutedTopic({
topic_id: 1,
message_type: "muted",
});
assert.equal(currentUser.muted_topics[0].topicId, 1);
state.trackMutedOrUnmutedTopic({ topic_id: 2, message_type: "unmuted" });
trackingState.trackMutedOrUnmutedTopic({
topic_id: 2,
message_type: "unmuted",
});
assert.equal(currentUser.unmuted_topics[0].topicId, 2);
state.pruneOldMutedAndUnmutedTopics();
assert.equal(state.isMutedTopic(1), true);
assert.equal(state.isUnmutedTopic(2), true);
trackingState.pruneOldMutedAndUnmutedTopics();
assert.equal(trackingState.isMutedTopic(1), true);
assert.equal(trackingState.isUnmutedTopic(2), true);
this.clock.tick(60000);
state.pruneOldMutedAndUnmutedTopics();
assert.equal(state.isMutedTopic(1), false);
assert.equal(state.isUnmutedTopic(2), false);
trackingState.pruneOldMutedAndUnmutedTopics();
assert.equal(trackingState.isMutedTopic(1), false);
assert.equal(trackingState.isUnmutedTopic(2), false);
});
});

View File

@ -914,7 +914,7 @@ class TopicsController < ApplicationController
topic_ids = params[:topic_ids].map { |t| t.to_i }
elsif params[:filter] == 'unread'
tq = TopicQuery.new(current_user)
topics = TopicQuery.unread_filter(tq.joined_topic_user, current_user.id, staff: guardian.is_staff?).listable_topics
topics = TopicQuery.unread_filter(tq.joined_topic_user, staff: guardian.is_staff?).listable_topics
topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"
if params[:category_id]

View File

@ -1,10 +1,20 @@
# frozen_string_literal: true
# this class is used to mirror unread and new status back to end users
# in JavaScript there is a mirror class that is kept in-sync using the mssage bus
# This class is used to mirror unread and new status back to end users
# in JavaScript there is a mirror class that is kept in-sync using MessageBus
# the allows end users to always know which topics have unread posts in them
# and which topics are new
# and which topics are new. This is used in various places in the UI, such as
# counters, indicators, and messages at the top of topic lists, so the user
# knows there is something worth reading at a glance.
#
# The TopicTrackingState.report data is preloaded in ApplicationController
# for the current user under the topicTrackingStates key, and the existing
# state is loaded into memory on page load. From there the MessageBus is
# used to keep topic state up to date, as well as syncing with topics from
# corresponding lists fetched from the server (e.g. the /new, /latest,
# /unread topic lists).
#
# See discourse/app/models/topic-tracking-state.js
class TopicTrackingState
include ActiveModel::SerializerSupport
@ -13,6 +23,13 @@ class TopicTrackingState
LATEST_MESSAGE_TYPE = "latest"
MUTED_MESSAGE_TYPE = "muted"
UNMUTED_MESSAGE_TYPE = "unmuted"
NEW_TOPIC_MESSAGE_TYPE = "new_topic"
RECOVER_MESSAGE_TYPE = "recover"
DELETE_MESSAGE_TYPE = "delete"
DESTROY_MESSAGE_TYPE = "destroy"
READ_MESSAGE_TYPE = "read"
DISMISS_NEW_MESSAGE_TYPE = "dismiss_new"
MAX_TOPICS = 5000
attr_accessor :user_id,
:topic_id,
@ -20,20 +37,15 @@ class TopicTrackingState
:last_read_post_number,
:created_at,
:category_id,
:notification_level
:notification_level,
:tags
def self.publish_new(topic)
return unless topic.regular?
tags, tag_ids = nil
tag_ids, tags = nil
if SiteSetting.tagging_enabled
topic.tags.pluck(:id, :name).each do |id, name|
tags ||= []
tag_ids ||= []
tags << name
tag_ids << id
end
tag_ids, tags = topic.tags.pluck(:id, :name).transpose
end
payload = {
@ -52,7 +64,7 @@ class TopicTrackingState
message = {
topic_id: topic.id,
message_type: "new_topic",
message_type: NEW_TOPIC_MESSAGE_TYPE,
payload: payload
}
@ -65,17 +77,26 @@ class TopicTrackingState
def self.publish_latest(topic, staff_only = false)
return unless topic.regular?
tag_ids, tags = nil
if SiteSetting.tagging_enabled
tag_ids, tags = topic.tags.pluck(:id, :name).transpose
end
message = {
topic_id: topic.id,
message_type: LATEST_MESSAGE_TYPE,
payload: {
bumped_at: topic.bumped_at,
category_id: topic.category_id,
archetype: topic.archetype,
topic_tag_ids: topic.tags.pluck(:id)
archetype: topic.archetype
}
}
if tags
message[:payload][:tags] = tags
message[:payload][:topic_tag_ids] = tag_ids
end
group_ids =
if staff_only
[Group::AUTO_GROUPS[:staff]]
@ -133,25 +154,32 @@ class TopicTrackingState
end
tags = nil
tag_ids = nil
if include_tags_in_report?
tags = post.topic.tags.pluck(:name)
tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose
end
TopicUser
.tracking(post.topic_id)
.includes(user: :user_stat)
.select([:user_id, :last_read_post_number, :notification_level])
.each do |tu|
payload = {
last_read_post_number: tu.last_read_post_number,
highest_post_number: post.post_number,
updated_at: post.topic.updated_at,
created_at: post.created_at,
category_id: post.topic.category_id,
notification_level: tu.notification_level,
archetype: post.topic.archetype
archetype: post.topic.archetype,
first_unread_at: tu.user.user_stat.first_unread_at
}
payload[:tags] = tags if tags
if tags
payload[:tags] = tags
payload[:topic_tag_ids] = tag_ids
end
message = {
topic_id: post.topic_id,
@ -169,7 +197,7 @@ class TopicTrackingState
message = {
topic_id: topic.id,
message_type: "recover"
message_type: RECOVER_MESSAGE_TYPE
}
MessageBus.publish("/recover", message.as_json, group_ids: group_ids)
@ -181,7 +209,7 @@ class TopicTrackingState
message = {
topic_id: topic.id,
message_type: "delete"
message_type: DELETE_MESSAGE_TYPE
}
MessageBus.publish("/delete", message.as_json, group_ids: group_ids)
@ -192,7 +220,7 @@ class TopicTrackingState
message = {
topic_id: topic.id,
message_type: "destroy"
message_type: DESTROY_MESSAGE_TYPE
}
MessageBus.publish("/destroy", message.as_json, group_ids: group_ids)
@ -203,7 +231,7 @@ class TopicTrackingState
message = {
topic_id: topic_id,
message_type: "read",
message_type: READ_MESSAGE_TYPE,
payload: {
last_read_post_number: last_read_post_number,
highest_post_number: highest_post_number,
@ -217,7 +245,7 @@ class TopicTrackingState
def self.publish_dismiss_new(user_id, topic_ids: [])
message = {
message_type: "dismiss_new",
message_type: DISMISS_NEW_MESSAGE_TYPE,
payload: {
topic_ids: topic_ids
}
@ -225,6 +253,18 @@ class TopicTrackingState
MessageBus.publish(self.unread_channel_key(user_id), message.as_json, user_ids: [user_id])
end
def self.new_filter_sql
TopicQuery.new_filter(
Topic, treat_as_new_topic_clause_sql: treat_as_new_topic_clause
).where_clause.ast.to_sql +
" AND topics.created_at > :min_new_topic_date" +
" AND dismissed_topic_users.id IS NULL"
end
def self.unread_filter_sql(staff: false)
TopicQuery.unread_filter(Topic, staff: staff).where_clause.ast.to_sql
end
def self.treat_as_new_topic_clause
User.where("GREATEST(CASE
WHEN COALESCE(uo.new_topic_duration_minutes, :default_duration) = :always THEN u.created_at
@ -247,18 +287,32 @@ class TopicTrackingState
@include_tags_in_report = v
end
# Sam: this is a hairy report, in particular I need custom joins and fancy conditions
# Dropping to sql_builder so I can make sense of it.
#
# Keep in mind, we need to be able to filter on a GROUP of users, and zero in on topic
# all our existing scope work does not do this
#
# This code needs to be VERY efficient as it is triggered via the message bus and may steal
# cycles from usual requests
def self.report(user, topic_id = nil)
# Sam: this is a hairy report, in particular I need custom joins and fancy conditions
# Dropping to sql_builder so I can make sense of it.
#
# Keep in mind, we need to be able to filter on a GROUP of users, and zero in on topic
# all our existing scope work does not do this
#
# This code needs to be VERY efficient as it is triggered via the message bus and may steal
# cycles from usual requests
tag_ids = muted_tag_ids(user)
sql = new_and_unread_sql(topic_id, user, tag_ids)
sql = tags_included_wrapped_sql(sql)
sql = +report_raw_sql(
report = DB.query(
sql + "\n\n LIMIT :max_topics",
user_id: user.id,
topic_id: topic_id,
min_new_topic_date: Time.at(SiteSetting.min_new_topics_time).to_datetime,
max_topics: TopicTrackingState::MAX_TOPICS
)
report
end
def self.new_and_unread_sql(topic_id, user, tag_ids)
sql = report_raw_sql(
topic_id: topic_id,
skip_unread: true,
skip_order: true,
@ -280,74 +334,80 @@ class TopicTrackingState
user: user,
muted_tag_ids: tag_ids
)
end
def self.tags_included_wrapped_sql(sql)
if SiteSetting.tagging_enabled && TopicTrackingState.include_tags_in_report?
sql = <<~SQL
WITH X AS (#{sql})
return <<~SQL
WITH tags_included_cte AS (
#{sql}
)
SELECT *, (
SELECT ARRAY_AGG(name) from topic_tags
JOIN tags on tags.id = topic_tags.tag_id
WHERE topic_id = X.topic_id
WHERE topic_id = tags_included_cte.topic_id
) tags
FROM X
FROM tags_included_cte
SQL
end
DB.query(
sql,
user_id: user.id,
topic_id: topic_id,
min_new_topic_date: Time.at(SiteSetting.min_new_topics_time).to_datetime
)
sql
end
def self.muted_tag_ids(user)
TagUser.lookup(user, :muted).pluck(:tag_id)
end
def self.report_raw_sql(opts = nil)
opts ||= {}
def self.report_raw_sql(
user:,
muted_tag_ids:,
topic_id: nil,
filter_old_unread: false,
skip_new: false,
skip_unread: false,
skip_order: false,
staff: false,
admin: false,
select: nil,
custom_state_filter: nil
)
unread =
if opts[:skip_unread]
if skip_unread
"1=0"
else
TopicQuery
.unread_filter(Topic, -999, staff: opts && opts[:staff])
.where_clause.ast.to_sql
.gsub("-999", ":user_id")
unread_filter_sql
end
filter_old_unread =
if opts[:filter_old_unread]
filter_old_unread_sql =
if filter_old_unread
" topics.updated_at >= us.first_unread_at AND "
else
""
end
new =
if opts[:skip_new]
if skip_new
"1=0"
else
TopicQuery.new_filter(Topic, "xxx").where_clause.ast.to_sql.gsub!("'xxx'", treat_as_new_topic_clause) +
" AND topics.created_at > :min_new_topic_date" +
" AND dismissed_topic_users.id IS NULL"
new_filter_sql
end
select = (opts[:select]) || "
u.id AS user_id,
topics.id AS topic_id,
select_sql = select || "
u.id as user_id,
topics.id as topic_id,
topics.created_at,
#{opts[:staff] ? "highest_staff_post_number highest_post_number" : "highest_post_number"},
topics.updated_at,
#{staff ? "highest_staff_post_number highest_post_number" : "highest_post_number"},
last_read_post_number,
c.id AS category_id,
tu.notification_level"
c.id as category_id,
tu.notification_level,
us.first_unread_at"
category_filter =
if opts[:admin]
if admin
""
else
append = "OR u.admin" if !opts.key?(:admin)
append = "OR u.admin" if !admin
<<~SQL
(
NOT c.read_restricted #{append} OR c.id IN (
@ -360,18 +420,18 @@ class TopicTrackingState
end
visibility_filter =
if opts[:staff]
if staff
""
else
append = "OR u.admin OR u.moderator" if !opts.key?(:staff)
append = "OR u.admin OR u.moderator" if !staff
"(topics.visible #{append}) AND"
end
tags_filter = ""
if (muted_tag_ids = opts[:muted_tag_ids]).present? && ['always', 'only_muted'].include?(SiteSetting.remove_muted_tags_from_latest)
if muted_tag_ids.present? && ['always', 'only_muted'].include?(SiteSetting.remove_muted_tags_from_latest)
existing_tags_sql = "(select array_agg(tag_id) from topic_tags where topic_tags.topic_id = topics.id)"
muted_tags_array_sql = "ARRAY[#{opts[:muted_tag_ids].join(',')}]"
muted_tags_array_sql = "ARRAY[#{muted_tag_ids.join(',')}]"
if SiteSetting.remove_muted_tags_from_latest == 'always'
tags_filter = <<~SQL
@ -389,34 +449,37 @@ class TopicTrackingState
end
sql = +<<~SQL
SELECT #{select}
FROM topics
JOIN users u on u.id = :user_id
JOIN user_stats AS us ON us.user_id = u.id
JOIN user_options AS uo ON uo.user_id = u.id
JOIN categories c ON c.id = topics.category_id
LEFT JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = u.id
LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{opts[:user].id}
LEFT JOIN dismissed_topic_users ON dismissed_topic_users.topic_id = topics.id AND dismissed_topic_users.user_id = #{opts[:user].id}
WHERE u.id = :user_id AND
#{filter_old_unread}
topics.archetype <> 'private_message' AND
((#{unread}) OR (#{new})) AND
#{visibility_filter}
#{tags_filter}
topics.deleted_at IS NULL AND
#{category_filter}
NOT (
last_read_post_number IS NULL AND
COALESCE(category_users.notification_level, #{CategoryUser.default_notification_level}) = #{CategoryUser.notification_levels[:muted]}
)
SQL
SELECT #{select_sql}
FROM topics
JOIN users u on u.id = :user_id
JOIN user_stats AS us ON us.user_id = u.id
JOIN user_options AS uo ON uo.user_id = u.id
JOIN categories c ON c.id = topics.category_id
LEFT JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = u.id
LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id}
LEFT JOIN dismissed_topic_users ON dismissed_topic_users.topic_id = topics.id AND dismissed_topic_users.user_id = #{user.id}
WHERE u.id = :user_id AND
#{filter_old_unread_sql}
topics.archetype <> 'private_message' AND
#{custom_state_filter ? custom_state_filter : "((#{unread}) OR (#{new})) AND"}
#{visibility_filter}
#{tags_filter}
topics.deleted_at IS NULL AND
#{category_filter}
NOT (
#{(skip_new && skip_unread) ? "" : "last_read_post_number IS NULL AND"}
(
COALESCE(category_users.notification_level, #{CategoryUser.default_notification_level}) = #{CategoryUser.notification_levels[:muted]}
AND tu.notification_level <= #{TopicUser.notification_levels[:regular]}
)
)
SQL
if opts[:topic_id]
if topic_id
sql << " AND topics.id = :topic_id"
end
unless opts[:skip_order]
unless skip_order
sql << " ORDER BY topics.bumped_at DESC"
end

View File

@ -35,6 +35,7 @@ class CurrentUserSerializer < BasicUserSerializer
:read_faq,
:automatically_unpin_topics,
:mailing_list_mode,
:treat_as_new_topic_start_date,
:previous_visit_at,
:seen_notification_id,
:primary_group_id,
@ -226,6 +227,10 @@ class CurrentUserSerializer < BasicUserSerializer
object.user_option.mailing_list_mode
end
def treat_as_new_topic_start_date
object.user_option.treat_as_new_topic_start_date
end
def skip_new_user_tips
object.user_option.skip_new_user_tips
end

View File

@ -331,7 +331,6 @@ class TopicQuery
list = TopicQuery.unread_filter(
list,
user.id,
staff: user.staff?
)
@ -379,14 +378,20 @@ class TopicQuery
end
end
def self.new_filter(list, treat_as_new_topic_start_date)
list.where("topics.created_at >= :created_at", created_at: treat_as_new_topic_start_date)
def self.new_filter(list, treat_as_new_topic_start_date: nil, treat_as_new_topic_clause_sql: nil)
if treat_as_new_topic_start_date
list = list.where("topics.created_at >= :created_at", created_at: treat_as_new_topic_start_date)
else
list = list.where("topics.created_at >= #{treat_as_new_topic_clause_sql}")
end
list
.where("tu.last_read_post_number IS NULL")
.where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking])
end
def self.unread_filter(list, user_id, opts)
col_name = opts[:staff] ? "highest_staff_post_number" : "highest_post_number"
def self.unread_filter(list, staff: false)
col_name = staff ? "highest_staff_post_number" : "highest_post_number"
list
.where("tu.last_read_post_number < topics.#{col_name}")
@ -516,7 +521,6 @@ class TopicQuery
def unread_results(options = {})
result = TopicQuery.unread_filter(
default_results(options.reverse_merge(unordered: true)),
@user&.id,
staff: @user&.staff?)
.order('CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END')
@ -547,7 +551,10 @@ class TopicQuery
def new_results(options = {})
# TODO does this make sense or should it be ordered on created_at
# it is ordering on bumped_at now
result = TopicQuery.new_filter(default_results(options.reverse_merge(unordered: true)), @user.user_option.treat_as_new_topic_start_date)
result = TopicQuery.new_filter(
default_results(options.reverse_merge(unordered: true)),
treat_as_new_topic_start_date: @user.user_option.treat_as_new_topic_start_date
)
result = remove_muted_topics(result, @user)
result = remove_muted_categories(result, @user, exclude: options[:category])
result = remove_muted_tags(result, @user, options)
@ -979,14 +986,16 @@ class TopicQuery
def new_messages(params)
TopicQuery
.new_filter(messages_for_groups_or_user(params[:my_group_ids]), Time.at(SiteSetting.min_new_topics_time).to_datetime)
.new_filter(
messages_for_groups_or_user(params[:my_group_ids]),
treat_as_new_topic_start_date: Time.at(SiteSetting.min_new_topics_time).to_datetime
)
.limit(params[:count])
end
def unread_messages(params)
query = TopicQuery.unread_filter(
messages_for_groups_or_user(params[:my_group_ids]),
@user.id,
staff: @user.staff?
)