diff --git a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js index 9b414b91198..8665981311e 100644 --- a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js +++ b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js @@ -21,19 +21,8 @@ const DiscoveryTopicsListComponent = Component.extend(UrlRefresh, LoadMore, { } }, - @on("didInsertElement") - _monitorTrackingState() { - this.stateChangeCallbackId = this.topicTrackingState.onStateChange( - this._updateTrackingTopics.bind(this) - ); - }, - - @on("willDestroyElement") - _removeTrackingStateChangeMonitor() { - this.topicTrackingState.offStateChange(this.stateChangeCallbackId); - }, - - _updateTrackingTopics() { + @observes("topicTrackingState.states") + _updateTopics() { this.topicTrackingState.updateTopics(this.model.topics); }, diff --git a/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js b/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js index 8562aa942b3..7ba734c65da 100644 --- a/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js +++ b/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js @@ -44,7 +44,9 @@ export default Mixin.create({ promise.then((result) => { if (result && result.topic_ids) { - this.topicTrackingState.removeTopics(result.topic_ids); + const tracker = this.topicTrackingState; + result.topic_ids.forEach((t) => tracker.removeTopic(t)); + tracker.incrementMessageCount(); } this.send("closeModal"); diff --git a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js index ee59b9ef6a5..b00e8427ba6 100644 --- a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js +++ b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js @@ -1,39 +1,27 @@ 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, currentUser) { - let createdInNewPeriod = true; - if (currentUser) { - createdInNewPeriod = - moment(topic.created_at) >= - moment(currentUser.treat_as_new_topic_start_date); - } +function isNew(topic) { 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 && - unreadNotTooOld + topic.notification_level >= NotificationLevels.TRACKING ); } @@ -61,46 +49,104 @@ 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() { - this.messageBus.subscribe("/new", this._processChannelPayload.bind(this)); - this.messageBus.subscribe( - "/latest", - this._processChannelPayload.bind(this) - ); + 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); if (this.currentUser) { this.messageBus.subscribe( "/unread/" + this.currentUser.get("id"), - this._processChannelPayload.bind(this) + process ); } this.messageBus.subscribe("/delete", (msg) => { - this.modifyStateProp(msg, "deleted", true); - this.incrementMessageCount(); + const old = tracker.states["t" + msg.topic_id]; + if (old) { + old.deleted = true; + } + tracker.incrementMessageCount(); }); this.messageBus.subscribe("/recover", (msg) => { - this.modifyStateProp(msg, "deleted", false); - this.incrementMessageCount(); + const old = tracker.states["t" + msg.topic_id]; + if (old) { + delete old.deleted; + } + tracker.incrementMessageCount(); }); this.messageBus.subscribe("/destroy", (msg) => { - this.incrementMessageCount(); + tracker.incrementMessageCount(); const currentRoute = DiscourseURL.router.currentRoute.parent; if ( currentRoute.name === "topic" && @@ -135,6 +181,17 @@ 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( @@ -156,50 +213,22 @@ 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.findState(topicId); - if (!state) { - return; - } - + const state = this.states["t" + topicId]; if ( - !state.last_read_post_number || - state.last_read_post_number < highestSeen + state && + (!state.last_read_post_number || + state.last_read_post_number < highestSeen) ) { - this.modifyStateProp(topicId, "last_read_post_number", highestSeen); + state.last_read_post_number = highestSeen; this.incrementMessageCount(); } }, - /** - * 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) { + notify(data) { if (!this.newIncoming) { return; } @@ -211,9 +240,6 @@ 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 ( @@ -224,67 +250,46 @@ 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.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 + const old = this.states["t" + data.topic_id]; 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); }, - /** - * 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 - */ + addIncoming(topicId) { + if (this.newIncoming.indexOf(topicId) === -1) { + this.newIncoming.push(topicId); + } + }, + resetTracking() { this.newIncoming = []; this.set("incomingCount", 0); }, - /** - * 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. - */ + // track how many new topics came for this filter trackIncoming(filter) { this.newIncoming = []; - 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("/") ); @@ -297,126 +302,145 @@ 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; }, - /** - * 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(); + removeTopic(topic_id) { + delete this.states["t" + topic_id]; }, - /** - * 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. - */ + // If we have a cached topic list, we can update it from our tracking information. updateTopics(topics) { if (isEmpty(topics)) { return; } - topics.forEach((topic) => { - const state = this.findState(topic.get("id")); + const states = this.states; + topics.forEach((t) => { + const state = states["t" + t.get("id")]; - if (!state) { - return; - } + 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; - const lastRead = topic.get("last_read_post_number"); - const isSeen = topic.get("is_seen"); + if (newPosts < 0) { + newPosts = 0; + } + if (!state.last_read_post_number) { + unread = 0; + } + if (unread < 0) { + unread = 0; + } - 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; + 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 (!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; } - // make sure any server-side state matches reality in the client side - this._fixDelayedServerState(list, filter); + // 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 all the state is up to date with what is accurate - // from the server - list.topics.forEach(this._syncStateFromListTopic.bind(this)); + list.topics.forEach(function (topic) { + const row = tracker.states["t" + topic.id] || {}; + row.topic_id = topic.id; + row.notification_level = topic.notification_level; - // correct missing states, safeguard in case message bus is corrupt - if (this._shouldCompensateState(list, filter, queryParams)) { - this._correctMissingState(list, filter); + 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; + } + }); } this.incrementMessageCount(); @@ -424,27 +448,6 @@ 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) { @@ -472,7 +475,7 @@ const TopicTrackingState = EmberObject.extend({ return Object.values(this.states).filter( (topic) => - filter(topic, this.currentUser) && + filter(topic) && topic.archetype !== "private_message" && !topic.deleted && (!categoryId || subcategoryIds.has(topic.category_id)) && @@ -496,71 +499,39 @@ const TopicTrackingState = EmberObject.extend({ ); }, - /** - * 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); + 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); + } + } }); }, - /** - * 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 = {}) { + countTags(tags) { let counts = {}; tags.forEach((tag) => { counts[tag] = { unreadCount: 0, newCount: 0 }; - 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++; - } + this.forEachTracked((topic, newTopic, unreadTopic) => { + if (topic.tags) { + tags.forEach((tag) => { + if (topic.tags.indexOf(tag) > -1) { + if (unreadTopic) { + counts[tag].unreadCount++; } - }); - } - }, - { includeAll: opts.includeTotal } - ); + if (newTopic) { + counts[tag].newCount++; + } + } + }); + } + }); return counts; }, @@ -606,272 +577,21 @@ const TopicTrackingState = EmberObject.extend({ }, loadStates(data) { - (data || []).forEach((topic) => { - this.modifyState(topic, topic); - }); - }, + const states = this.states; - 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; - } + // 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; }); } - - 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"); } diff --git a/app/assets/javascripts/discourse/tests/helpers/site-settings.js b/app/assets/javascripts/discourse/tests/helpers/site-settings.js index 2b86811813b..3726d181087 100644 --- a/app/assets/javascripts/discourse/tests/helpers/site-settings.js +++ b/app/assets/javascripts/discourse/tests/helpers/site-settings.js @@ -98,7 +98,6 @@ const ORIGINAL_SETTINGS = { unicode_usernames: false, secure_media: false, external_emoji_url: "", - remove_muted_tags_from_latest: "always", }; let siteSettings = Object.assign({}, ORIGINAL_SETTINGS); diff --git a/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js b/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js index 42905824156..329aa30fa68 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js @@ -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.modifyState("t1", { topic_id: 1, last_read_post_number: null }); + tracker.states["t1"] = { topic_id: 1, last_read_post_number: null }; tracker.incrementMessageCount(); assert.equal( diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js index 307fb8fb247..56542dfb959 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js @@ -1,20 +1,12 @@ -import { test } from "qunit"; -import DiscourseURL from "discourse/lib/url"; -import { getProperties } from "@ember/object"; +import { module, test } from "qunit"; 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"; -discourseModule("Unit | Model | topic-tracking-state", function (hooks) { +module("Unit | Model | topic-tracking-state", function (hooks) { hooks.beforeEach(function () { this.clock = sinon.useFakeTimers(new Date(2012, 11, 31, 12, 0).getTime()); }); @@ -24,18 +16,18 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }); test("tag counts", function (assert) { - const trackingState = TopicTrackingState.create(); + const state = TopicTrackingState.create(); - trackingState.loadStates([ + state.loadStates([ { topic_id: 1, last_read_post_number: null, - tags: ["foo", "baz"], + tags: ["foo", "new"], }, { topic_id: 2, last_read_post_number: null, - tags: ["baz"], + tags: ["new"], }, { topic_id: 3, @@ -46,14 +38,14 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { topic_id: 4, last_read_post_number: 1, highest_post_number: 7, - tags: ["pending"], + tags: ["unread"], notification_level: NotificationLevels.TRACKING, }, { topic_id: 5, last_read_post_number: 1, highest_post_number: 7, - tags: ["bar", "pending"], + tags: ["bar", "unread"], notification_level: NotificationLevels.TRACKING, }, { @@ -65,92 +57,18 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }, ]); - const tagCounts = trackingState.countTags(["baz", "pending"]); + const states = state.countTags(["new", "unread"]); - 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"); + 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"); }); test("forEachTracked", function (assert) { - const trackingState = TopicTrackingState.create(); + const state = TopicTrackingState.create(); - trackingState.loadStates([ + state.loadStates([ { topic_id: 1, last_read_post_number: null, @@ -196,7 +114,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { sevenUnread = 0, sevenNew = 0; - trackingState.forEachTracked((topic, isNew, isUnread) => { + state.forEachTracked((topic, isNew, isUnread) => { if (topic.category_id === 7) { if (isNew) { sevenNew += 1; @@ -222,11 +140,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { assert.equal(sevenUnread, 2, "seven unread"); }); - 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 }]); + test("sync", function (assert) { + const state = TopicTrackingState.create(); + state.states["t111"] = { last_read_post_number: null }; - trackingState.updateSeen(111, 7); + state.updateSeen(111, 7); const list = { topics: [ { @@ -238,7 +156,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { ], }; - trackingState.sync(list, "new"); + state.sync(list, "new"); assert.equal( list.topics.length, 0, @@ -246,496 +164,6 @@ discourseModule("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" }), @@ -748,53 +176,53 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { sinon.stub(Category, "list").returns(categoryList); - const trackingState = TopicTrackingState.create(); + const state = TopicTrackingState.create(); - trackingState.trackIncoming("c/darth/1/l/latest"); + state.trackIncoming("c/darth/1/l/latest"); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 1, payload: { category_id: 2, topic_id: 1 }, }); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 2, payload: { category_id: 3, topic_id: 2 }, }); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 3, payload: { category_id: 1, topic_id: 3 }, }); assert.equal( - trackingState.get("incomingCount"), + state.get("incomingCount"), 2, "expect to properly track incoming for category" ); - trackingState.resetTracking(); - trackingState.trackIncoming("c/darth/luke/2/l/latest"); + state.resetTracking(); + state.trackIncoming("c/darth/luke/2/l/latest"); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 1, payload: { category_id: 2, topic_id: 1 }, }); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 2, payload: { category_id: 3, topic_id: 2 }, }); - trackingState.notifyIncoming({ + state.notify({ message_type: "new_topic", topic_id: 3, payload: { category_id: 1, topic_id: 3 }, }); assert.equal( - trackingState.get("incomingCount"), + state.get("incomingCount"), 1, "expect to properly track incoming for subcategory" ); @@ -815,10 +243,10 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }); sinon.stub(Category, "list").returns([foo, bar, baz]); - 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]); + 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]); }); test("countNew", function (assert) { @@ -848,26 +276,26 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { muted_category_ids: [4], }); - const trackingState = TopicTrackingState.create({ currentUser }); + const state = TopicTrackingState.create({ currentUser }); - assert.equal(trackingState.countNew(1), 0); - assert.equal(trackingState.countNew(2), 0); - assert.equal(trackingState.countNew(3), 0); + assert.equal(state.countNew(1), 0); + assert.equal(state.countNew(2), 0); + assert.equal(state.countNew(3), 0); - trackingState.states["t112"] = { + state.states["t112"] = { last_read_post_number: null, id: 112, notification_level: NotificationLevels.TRACKING, category_id: 2, }; - 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); + 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); - trackingState.states["t113"] = { + state.states["t113"] = { last_read_post_number: null, id: 113, notification_level: NotificationLevels.TRACKING, @@ -875,29 +303,52 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { tags: ["amazing"], }; - 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); + 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); - trackingState.states["t111"] = { + state.states["t111"] = { last_read_post_number: null, id: 111, notification_level: NotificationLevels.TRACKING, category_id: 1, }; - assert.equal(trackingState.countNew(1), 3); - assert.equal(trackingState.countNew(2), 2); - assert.equal(trackingState.countNew(3), 1); + assert.equal(state.countNew(1), 3); + assert.equal(state.countNew(2), 2); + assert.equal(state.countNew(3), 1); - trackingState.states["t115"] = { + state.states["t115"] = { last_read_post_number: null, id: 115, category_id: 4, }; - assert.equal(trackingState.countNew(4), 0); + 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); }); test("mute and unmute topic", function (assert) { @@ -906,27 +357,21 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { muted_category_ids: [], }); - const trackingState = TopicTrackingState.create({ currentUser }); + const state = TopicTrackingState.create({ currentUser }); - trackingState.trackMutedOrUnmutedTopic({ - topic_id: 1, - message_type: "muted", - }); + state.trackMutedOrUnmutedTopic({ topic_id: 1, message_type: "muted" }); assert.equal(currentUser.muted_topics[0].topicId, 1); - trackingState.trackMutedOrUnmutedTopic({ - topic_id: 2, - message_type: "unmuted", - }); + state.trackMutedOrUnmutedTopic({ topic_id: 2, message_type: "unmuted" }); assert.equal(currentUser.unmuted_topics[0].topicId, 2); - trackingState.pruneOldMutedAndUnmutedTopics(); - assert.equal(trackingState.isMutedTopic(1), true); - assert.equal(trackingState.isUnmutedTopic(2), true); + state.pruneOldMutedAndUnmutedTopics(); + assert.equal(state.isMutedTopic(1), true); + assert.equal(state.isUnmutedTopic(2), true); this.clock.tick(60000); - trackingState.pruneOldMutedAndUnmutedTopics(); - assert.equal(trackingState.isMutedTopic(1), false); - assert.equal(trackingState.isUnmutedTopic(2), false); + state.pruneOldMutedAndUnmutedTopics(); + assert.equal(state.isMutedTopic(1), false); + assert.equal(state.isUnmutedTopic(2), false); }); }); diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index ae17de2c420..343178d9a94 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -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, staff: guardian.is_staff?).listable_topics + topics = TopicQuery.unread_filter(tq.joined_topic_user, current_user.id, staff: guardian.is_staff?).listable_topics topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true" if params[:category_id] diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index e61c4f968ef..75c2e9d9745 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -1,20 +1,10 @@ # 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 MessageBus +# 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 # the allows end users to always know which topics have unread posts in them -# 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 +# and which topics are new + class TopicTrackingState include ActiveModel::SerializerSupport @@ -23,13 +13,6 @@ 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, @@ -37,15 +20,20 @@ class TopicTrackingState :last_read_post_number, :created_at, :category_id, - :notification_level, - :tags + :notification_level def self.publish_new(topic) return unless topic.regular? - tag_ids, tags = nil + tags, tag_ids = nil if SiteSetting.tagging_enabled - tag_ids, tags = topic.tags.pluck(:id, :name).transpose + topic.tags.pluck(:id, :name).each do |id, name| + tags ||= [] + tag_ids ||= [] + + tags << name + tag_ids << id + end end payload = { @@ -64,7 +52,7 @@ class TopicTrackingState message = { topic_id: topic.id, - message_type: NEW_TOPIC_MESSAGE_TYPE, + message_type: "new_topic", payload: payload } @@ -77,26 +65,17 @@ 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 + archetype: topic.archetype, + topic_tag_ids: topic.tags.pluck(:id) } } - if tags - message[:payload][:tags] = tags - message[:payload][:topic_tag_ids] = tag_ids - end - group_ids = if staff_only [Group::AUTO_GROUPS[:staff]] @@ -154,32 +133,25 @@ class TopicTrackingState end tags = nil - tag_ids = nil if include_tags_in_report? - tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose + tags = post.topic.tags.pluck(:name) 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, - first_unread_at: tu.user.user_stat.first_unread_at + archetype: post.topic.archetype } - if tags - payload[:tags] = tags - payload[:topic_tag_ids] = tag_ids - end + payload[:tags] = tags if tags message = { topic_id: post.topic_id, @@ -197,7 +169,7 @@ class TopicTrackingState message = { topic_id: topic.id, - message_type: RECOVER_MESSAGE_TYPE + message_type: "recover" } MessageBus.publish("/recover", message.as_json, group_ids: group_ids) @@ -209,7 +181,7 @@ class TopicTrackingState message = { topic_id: topic.id, - message_type: DELETE_MESSAGE_TYPE + message_type: "delete" } MessageBus.publish("/delete", message.as_json, group_ids: group_ids) @@ -220,7 +192,7 @@ class TopicTrackingState message = { topic_id: topic.id, - message_type: DESTROY_MESSAGE_TYPE + message_type: "destroy" } MessageBus.publish("/destroy", message.as_json, group_ids: group_ids) @@ -231,7 +203,7 @@ class TopicTrackingState message = { topic_id: topic_id, - message_type: READ_MESSAGE_TYPE, + message_type: "read", payload: { last_read_post_number: last_read_post_number, highest_post_number: highest_post_number, @@ -245,7 +217,7 @@ class TopicTrackingState def self.publish_dismiss_new(user_id, topic_ids: []) message = { - message_type: DISMISS_NEW_MESSAGE_TYPE, + message_type: "dismiss_new", payload: { topic_ids: topic_ids } @@ -253,18 +225,6 @@ 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 @@ -287,32 +247,18 @@ 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) - 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( + sql = +report_raw_sql( topic_id: topic_id, skip_unread: true, skip_order: true, @@ -334,80 +280,74 @@ 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? - return <<~SQL - WITH tags_included_cte AS ( - #{sql} - ) + sql = <<~SQL + WITH X AS (#{sql}) SELECT *, ( SELECT ARRAY_AGG(name) from topic_tags JOIN tags on tags.id = topic_tags.tag_id - WHERE topic_id = tags_included_cte.topic_id + WHERE topic_id = X.topic_id ) tags - FROM tags_included_cte + FROM X SQL end - sql + DB.query( + sql, + user_id: user.id, + topic_id: topic_id, + min_new_topic_date: Time.at(SiteSetting.min_new_topics_time).to_datetime + ) end def self.muted_tag_ids(user) TagUser.lookup(user, :muted).pluck(:tag_id) end - 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 - ) + def self.report_raw_sql(opts = nil) + opts ||= {} + unread = - if skip_unread + if opts[:skip_unread] "1=0" else - unread_filter_sql + TopicQuery + .unread_filter(Topic, -999, staff: opts && opts[:staff]) + .where_clause.ast.to_sql + .gsub("-999", ":user_id") end - filter_old_unread_sql = - if filter_old_unread + filter_old_unread = + if opts[:filter_old_unread] " topics.updated_at >= us.first_unread_at AND " else "" end new = - if skip_new + if opts[:skip_new] "1=0" else - new_filter_sql + 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" end - select_sql = select || " - u.id as user_id, - topics.id as topic_id, + select = (opts[:select]) || " + u.id AS user_id, + topics.id AS topic_id, topics.created_at, - topics.updated_at, - #{staff ? "highest_staff_post_number highest_post_number" : "highest_post_number"}, + #{opts[:staff] ? "highest_staff_post_number highest_post_number" : "highest_post_number"}, last_read_post_number, - c.id as category_id, - tu.notification_level, - us.first_unread_at" + c.id AS category_id, + tu.notification_level" category_filter = - if admin + if opts[:admin] "" else - append = "OR u.admin" if !admin + append = "OR u.admin" if !opts.key?(:admin) <<~SQL ( NOT c.read_restricted #{append} OR c.id IN ( @@ -420,18 +360,18 @@ class TopicTrackingState end visibility_filter = - if staff + if opts[:staff] "" else - append = "OR u.admin OR u.moderator" if !staff + append = "OR u.admin OR u.moderator" if !opts.key?(:staff) "(topics.visible #{append}) AND" end tags_filter = "" - if muted_tag_ids.present? && ['always', 'only_muted'].include?(SiteSetting.remove_muted_tags_from_latest) + if (muted_tag_ids = opts[: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[#{muted_tag_ids.join(',')}]" + muted_tags_array_sql = "ARRAY[#{opts[:muted_tag_ids].join(',')}]" if SiteSetting.remove_muted_tags_from_latest == 'always' tags_filter = <<~SQL @@ -449,37 +389,34 @@ class TopicTrackingState end sql = +<<~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 + 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 - if topic_id + if opts[:topic_id] sql << " AND topics.id = :topic_id" end - unless skip_order + unless opts[:skip_order] sql << " ORDER BY topics.bumped_at DESC" end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 41957fef46f..8f1a9a8bfe0 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -35,7 +35,6 @@ 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, @@ -227,10 +226,6 @@ 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 diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 5a648b9f270..d769c1d1d1f 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -331,6 +331,7 @@ class TopicQuery list = TopicQuery.unread_filter( list, + user.id, staff: user.staff? ) @@ -378,20 +379,14 @@ class TopicQuery end end - 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 + 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) .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, staff: false) - col_name = staff ? "highest_staff_post_number" : "highest_post_number" + def self.unread_filter(list, user_id, opts) + col_name = opts[:staff] ? "highest_staff_post_number" : "highest_post_number" list .where("tu.last_read_post_number < topics.#{col_name}") @@ -521,6 +516,7 @@ 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') @@ -551,10 +547,7 @@ 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)), - treat_as_new_topic_start_date: @user.user_option.treat_as_new_topic_start_date - ) + result = TopicQuery.new_filter(default_results(options.reverse_merge(unordered: true)), @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) @@ -986,16 +979,14 @@ class TopicQuery def new_messages(params) TopicQuery - .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 - ) + .new_filter(messages_for_groups_or_user(params[:my_group_ids]), 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? )