This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6
Sam 335be272ff FEATURE: implement capping of new/unread
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
2015-09-07 12:03:17 +10:00

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;