We cap new and unread at 2/5th of SiteSetting.max_tracked_new_unread This dynamic capping is applied under 2 conditions: 1. New capping is applied once every 15 minutes in the periodical job, this effectively ensures that usually even super active sites are capped at 200 new items 2. Unread capping is applied if a user hits max_tracked_new_unread, meaning if new + unread == 500, we defer a job that runs within 15 minutes that will cap user at 200 unread This logic ensures that at worst case a user gets "bad" numbers for 15 minutes and then the system goes ahead and fixes itself up
327 lines
9.2 KiB
JavaScript
327 lines
9.2 KiB
JavaScript
import NotificationLevels from 'discourse/lib/notification-levels';
|
|
import computed from "ember-addons/ember-computed-decorators";
|
|
import { on } from "ember-addons/ember-computed-decorators";
|
|
|
|
function isNew(topic) {
|
|
return topic.last_read_post_number === null &&
|
|
((topic.notification_level !== 0 && !topic.notification_level) ||
|
|
topic.notification_level >= NotificationLevels.TRACKING);
|
|
}
|
|
|
|
function isUnread(topic) {
|
|
return topic.last_read_post_number !== null &&
|
|
topic.last_read_post_number < topic.highest_post_number &&
|
|
topic.notification_level >= NotificationLevels.TRACKING;
|
|
}
|
|
|
|
const TopicTrackingState = Discourse.Model.extend({
|
|
messageCount: 0,
|
|
|
|
@on("init")
|
|
_setup() {
|
|
this.unreadSequence = [];
|
|
this.newSequence = [];
|
|
this.states = {};
|
|
},
|
|
|
|
establishChannels() {
|
|
const tracker = this;
|
|
|
|
const process = data => {
|
|
if (data.message_type === "delete") {
|
|
tracker.removeTopic(data.topic_id);
|
|
tracker.incrementMessageCount();
|
|
}
|
|
|
|
if (data.message_type === "new_topic" || data.message_type === "latest") {
|
|
const muted_category_ids = Discourse.User.currentProp("muted_category_ids");
|
|
if (_.include(muted_category_ids, data.payload.category_id)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (data.message_type === "latest"){
|
|
tracker.notify(data);
|
|
}
|
|
|
|
if (data.message_type === "new_topic" || data.message_type === "unread" || data.message_type === "read") {
|
|
tracker.notify(data);
|
|
const old = tracker.states["t" + data.topic_id];
|
|
|
|
if (!_.isEqual(old, data.payload)) {
|
|
tracker.states["t" + data.topic_id] = data.payload;
|
|
tracker.incrementMessageCount();
|
|
}
|
|
}
|
|
};
|
|
|
|
this.messageBus.subscribe("/new", process);
|
|
this.messageBus.subscribe("/latest", process);
|
|
if (this.currentUser) {
|
|
this.messageBus.subscribe("/unread/" + this.currentUser.get('id'), process);
|
|
}
|
|
},
|
|
|
|
updateSeen(topicId, highestSeen) {
|
|
if (!topicId || !highestSeen) { return; }
|
|
const state = this.states["t" + topicId];
|
|
if (state && (!state.last_read_post_number || state.last_read_post_number < highestSeen)) {
|
|
state.last_read_post_number = highestSeen;
|
|
this.incrementMessageCount();
|
|
}
|
|
},
|
|
|
|
notify(data) {
|
|
if (!this.newIncoming) { return; }
|
|
|
|
const filter = this.get("filter");
|
|
|
|
if (filter === Discourse.Utilities.defaultHomepage()) {
|
|
const suppressed_from_homepage_category_ids = Discourse.Site.currentProp("suppressed_from_homepage_category_ids");
|
|
if (_.include(suppressed_from_homepage_category_ids, data.payload.category_id)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if ((filter === "all" || filter === "latest" || filter === "new") && data.message_type === "new_topic") {
|
|
this.addIncoming(data.topic_id);
|
|
}
|
|
|
|
if ((filter === "all" || filter === "unread") && data.message_type === "unread") {
|
|
const old = this.states["t" + data.topic_id];
|
|
if(!old || old.highest_post_number === old.last_read_post_number) {
|
|
this.addIncoming(data.topic_id);
|
|
}
|
|
}
|
|
|
|
if (filter === "latest" && data.message_type === "latest") {
|
|
this.addIncoming(data.topic_id);
|
|
}
|
|
|
|
this.set("incomingCount", this.newIncoming.length);
|
|
},
|
|
|
|
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 this filter
|
|
trackIncoming(filter) {
|
|
this.newIncoming = [];
|
|
this.set("filter", filter);
|
|
this.set("incomingCount", 0);
|
|
},
|
|
|
|
@computed("incomingCount")
|
|
hasIncoming(incomingCount) {
|
|
return incomingCount && incomingCount > 0;
|
|
},
|
|
|
|
removeTopic(topic_id) {
|
|
delete this.states["t" + topic_id];
|
|
},
|
|
|
|
// If we have a cached topic list, we can update it from our tracking
|
|
// information.
|
|
updateTopics(topics) {
|
|
if (Em.isEmpty(topics)) { return; }
|
|
|
|
const states = this.states;
|
|
topics.forEach(t => {
|
|
const state = states['t' + t.get('id')];
|
|
|
|
if (state) {
|
|
const lastRead = t.get('last_read_post_number');
|
|
if (lastRead !== state.last_read_post_number) {
|
|
const postsCount = t.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; }
|
|
|
|
t.setProperties({
|
|
highest_post_number: state.highest_post_number,
|
|
last_read_post_number: state.last_read_post_number,
|
|
new_posts: newPosts,
|
|
unread: unread,
|
|
unseen: !state.last_read_post_number
|
|
});
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
sync(list, filter) {
|
|
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].unseen = false;
|
|
list.topics[i].dont_sync = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
list.topics.forEach(function(topic){
|
|
const row = tracker.states["t" + topic.id] || {};
|
|
row.topic_id = topic.id;
|
|
row.notification_level = topic.notification_level;
|
|
|
|
|
|
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;
|
|
}
|
|
|
|
tracker.states["t" + topic.id] = row;
|
|
});
|
|
|
|
// Correct missing states, safeguard in case message bus is corrupt
|
|
if ((filter === "new" || filter === "unread") && !list.more_topics_url) {
|
|
|
|
const ids = {};
|
|
list.topics.forEach(r => ids["t" + r.id] = true);
|
|
|
|
_.each(tracker.states, (v, k) => {
|
|
|
|
// we are good if we are on the list
|
|
if (ids[k]) { return; }
|
|
|
|
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();
|
|
},
|
|
|
|
incrementMessageCount() {
|
|
this.set("messageCount", this.get("messageCount") + 1);
|
|
},
|
|
|
|
countNew(category_id) {
|
|
return _.chain(this.states)
|
|
.where(isNew)
|
|
.where(topic => topic.category_id === category_id || !category_id)
|
|
.value()
|
|
.length;
|
|
},
|
|
|
|
resetNew() {
|
|
Object.keys(this.states).forEach(id => {
|
|
if (this.states[id].last_read_post_number === null) {
|
|
delete this.states[id];
|
|
}
|
|
});
|
|
},
|
|
|
|
countUnread(category_id) {
|
|
return _.chain(this.states)
|
|
.where(isUnread)
|
|
.where(topic => topic.category_id === category_id || !category_id)
|
|
.value()
|
|
.length;
|
|
},
|
|
|
|
countCategory(category_id) {
|
|
let sum = 0;
|
|
_.each(this.states, function(topic){
|
|
if (topic.category_id === category_id) {
|
|
sum += (topic.last_read_post_number === null ||
|
|
topic.last_read_post_number < topic.highest_post_number) ? 1 : 0;
|
|
}
|
|
});
|
|
return sum;
|
|
},
|
|
|
|
lookupCount(name, category) {
|
|
if (name === "latest") {
|
|
return this.lookupCount("new", category) +
|
|
this.lookupCount("unread", category);
|
|
}
|
|
|
|
let categoryName = category ? Em.get(category, "name") : null;
|
|
if (name === "new") {
|
|
return this.countNew(categoryName);
|
|
} else if (name === "unread") {
|
|
return this.countUnread(categoryName);
|
|
} else {
|
|
categoryName = name.split("/")[1];
|
|
if (categoryName) {
|
|
return this.countCategory(categoryName);
|
|
}
|
|
}
|
|
},
|
|
|
|
loadStates(data) {
|
|
const states = this.states;
|
|
if (data) {
|
|
_.each(data,topic => states["t" + topic.topic_id] = topic);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
TopicTrackingState.reopenClass({
|
|
|
|
createFromStates(data) {
|
|
// TODO: This should be a model that does injection automatically
|
|
const container = Discourse.__container__,
|
|
messageBus = container.lookup('message-bus:main'),
|
|
currentUser = container.lookup('current-user:main'),
|
|
instance = TopicTrackingState.create({ messageBus, currentUser });
|
|
|
|
instance.loadStates(data);
|
|
instance.initialStatesLength = data && data.length;
|
|
instance.establishChannels();
|
|
return instance;
|
|
},
|
|
|
|
current() {
|
|
if (!this.tracker) {
|
|
const data = PreloadStore.get('topicTrackingStates');
|
|
this.tracker = this.createFromStates(data);
|
|
PreloadStore.remove('topicTrackingStates');
|
|
}
|
|
return this.tracker;
|
|
}
|
|
});
|
|
|
|
export default TopicTrackingState;
|