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/app/models/post-stream.js
Sam 32393f72b1
PERF: backoff background requests when overloaded (#10888)
When the server gets overloaded and lots of requests start queuing server
will attempt to shed load by returning 429 errors on background requests.

The client can flag a request as background by setting the header:
`Discourse-Background` to `true`

Out-of-the-box we shed load when the queue time goes above 0.5 seconds.

The only request we shed at the moment is the request to load up a new post
when someone posts to a topic.

We can extend this as we go with a more general pattern on the client.

Previous to this change, rate limiting would "break" the post stream which
would make suggested topics vanish and users would have to scroll the page
to see more posts in the topic.

Server needs this protection for cases where tons of clients are navigated
to a topic and a new post is made. This can lead to a self inflicted denial
of service if enough clients are viewing the topic.

Due to the internal security design of Discourse it is hard for a large
number of clients to share a channel where we would pass the full post body
via the message bus.

It also renames (and deprecates) triggerNewPostInStream to triggerNewPostsInStream

This allows us to load a batch of new posts cleanly, so the controller can
keep track of a backlog

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2020-10-13 16:56:03 +11:00

1137 lines
29 KiB
JavaScript

import I18n from "I18n";
import { get } from "@ember/object";
import { isEmpty } from "@ember/utils";
import { or, not, and } from "@ember/object/computed";
import { ajax } from "discourse/lib/ajax";
import DiscourseURL from "discourse/lib/url";
import RestModel from "discourse/models/rest";
import PostsWithPlaceholders from "discourse/lib/posts-with-placeholders";
import discourseComputed from "discourse-common/utils/decorators";
import { loadTopicView } from "discourse/models/topic";
import { Promise } from "rsvp";
import User from "discourse/models/user";
import { deepMerge } from "discourse-common/lib/object";
import deprecated from "discourse-common/lib/deprecated";
export default RestModel.extend({
_identityMap: null,
posts: null,
stream: null,
userFilters: null,
summary: null,
loaded: null,
loadingAbove: null,
loadingBelow: null,
loadingFilter: null,
loadingNearPost: null,
stagingPost: null,
postsWithPlaceholders: null,
timelineLookup: null,
init() {
this._identityMap = {};
const posts = [];
const postsWithPlaceholders = PostsWithPlaceholders.create({
posts,
store: this.store,
});
this.setProperties({
posts,
postsWithPlaceholders,
stream: [],
userFilters: [],
summary: false,
loaded: false,
loadingAbove: false,
loadingBelow: false,
loadingFilter: false,
stagingPost: false,
timelineLookup: [],
});
},
loading: or("loadingAbove", "loadingBelow", "loadingFilter", "stagingPost"),
notLoading: not("loading"),
@discourseComputed(
"isMegaTopic",
"stream.length",
"topic.highest_post_number"
)
filteredPostsCount(isMegaTopic, streamLength, topicHighestPostNumber) {
return isMegaTopic ? topicHighestPostNumber : streamLength;
},
@discourseComputed("posts.[]")
hasPosts() {
return this.get("posts.length") > 0;
},
@discourseComputed("hasPosts", "filteredPostsCount")
hasLoadedData(hasPosts, filteredPostsCount) {
return hasPosts && filteredPostsCount > 0;
},
canAppendMore: and("notLoading", "hasPosts", "lastPostNotLoaded"),
canPrependMore: and("notLoading", "hasPosts", "firstPostNotLoaded"),
@discourseComputed("hasLoadedData", "firstPostId", "posts.[]")
firstPostPresent(hasLoadedData, firstPostId) {
if (!hasLoadedData) {
return false;
}
return !!this.posts.findBy("id", firstPostId);
},
firstPostNotLoaded: not("firstPostPresent"),
firstId: null,
lastId: null,
@discourseComputed("isMegaTopic", "stream.firstObject", "firstId")
firstPostId(isMegaTopic, streamFirstId, firstId) {
return isMegaTopic ? firstId : streamFirstId;
},
@discourseComputed("isMegaTopic", "stream.lastObject", "lastId")
lastPostId(isMegaTopic, streamLastId, lastId) {
return isMegaTopic ? lastId : streamLastId;
},
@discourseComputed("hasLoadedData", "lastPostId", "posts.@each.id")
loadedAllPosts(hasLoadedData, lastPostId) {
if (!hasLoadedData) {
return false;
}
if (lastPostId === -1) {
return true;
}
return !!this.posts.findBy("id", lastPostId);
},
lastPostNotLoaded: not("loadedAllPosts"),
/**
Returns a JS Object of current stream filter options. It should match the query
params for the stream.
**/
@discourseComputed("summary", "userFilters.[]")
streamFilters(summary) {
const result = {};
if (summary) {
result.filter = "summary";
}
const userFilters = this.userFilters;
if (!isEmpty(userFilters)) {
result.username_filters = userFilters.join(",");
}
return result;
},
@discourseComputed("streamFilters.[]", "topic.posts_count", "posts.length")
hasNoFilters() {
const streamFilters = this.streamFilters;
return !(
streamFilters &&
(streamFilters.filter === "summary" || streamFilters.username_filters)
);
},
/**
Returns the window of posts above the current set in the stream, bound to the top of the stream.
This is the collection we'll ask for when scrolling upwards.
**/
@discourseComputed("posts.[]", "stream.[]")
previousWindow() {
if (!this.posts) {
return [];
}
// If we can't find the last post loaded, bail
const firstPost = this.posts[0];
if (!firstPost) {
return [];
}
// Find the index of the last post loaded, if not found, bail
const stream = this.stream;
const firstIndex = this.indexOf(firstPost);
if (firstIndex === -1) {
return [];
}
let startIndex = firstIndex - this.get("topic.chunk_size");
if (startIndex < 0) {
startIndex = 0;
}
return stream.slice(startIndex, firstIndex);
},
/**
Returns the window of posts below the current set in the stream, bound by the bottom of the
stream. This is the collection we use when scrolling downwards.
**/
@discourseComputed("posts.lastObject", "stream.[]")
nextWindow(lastLoadedPost) {
// If we can't find the last post loaded, bail
if (!lastLoadedPost) {
return [];
}
// Find the index of the last post loaded, if not found, bail
const stream = this.stream;
const lastIndex = this.indexOf(lastLoadedPost);
if (lastIndex === -1) {
return [];
}
if (lastIndex + 1 >= this.highest_post_number) {
return [];
}
// find our window of posts
return stream.slice(
lastIndex + 1,
lastIndex + this.get("topic.chunk_size") + 1
);
},
cancelFilter() {
this.set("summary", false);
this.userFilters.clear();
},
toggleSummary() {
this.userFilters.clear();
this.toggleProperty("summary");
const opts = {};
if (!this.summary) {
opts.filter = "none";
}
return this.refresh(opts).then(() => {
if (this.summary) {
this.jumpToSecondVisible();
}
});
},
jumpToSecondVisible() {
const posts = this.posts;
if (posts.length > 1) {
const secondPostNum = posts[1].get("post_number");
DiscourseURL.jumpToPost(secondPostNum);
}
},
// Filter the stream to a particular user.
toggleParticipant(username) {
const userFilters = this.userFilters;
this.set("summary", false);
let jump = false;
if (userFilters.includes(username)) {
userFilters.removeObject(username);
} else {
userFilters.addObject(username);
jump = true;
}
return this.refresh().then(() => {
if (jump) {
this.jumpToSecondVisible();
}
});
},
/**
Loads a new set of posts into the stream. If you provide a `nearPost` option and the post
is already loaded, it will simply scroll there and load nothing.
**/
refresh(opts) {
opts = opts || {};
opts.nearPost = parseInt(opts.nearPost, 10);
if (opts.cancelSummary) {
this.set("summary", false);
delete opts.cancelSummary;
}
const topic = this.topic;
// Do we already have the post in our list of posts? Jump there.
if (opts.forceLoad) {
this.set("loaded", false);
} else {
const postWeWant = this.posts.findBy("post_number", opts.nearPost);
if (postWeWant) {
return Promise.resolve();
}
}
// TODO: if we have all the posts in the filter, don't go to the server for them.
this.set("loadingFilter", true);
this.set("loadingNearPost", opts.nearPost);
opts = deepMerge(opts, this.streamFilters);
// Request a topicView
return loadTopicView(topic, opts)
.then((json) => {
this.updateFromJson(json.post_stream);
this.setProperties({
loadingFilter: false,
timelineLookup: json.timeline_lookup,
loaded: true,
});
})
.catch((result) => {
this.errorLoading(result);
throw new Error(result);
})
.finally(() => {
this.set("loadingNearPost", null);
});
},
// Fill in a gap of posts before a particular post
fillGapBefore(post, gap) {
const postId = post.get("id"),
stream = this.stream,
idx = stream.indexOf(postId),
currentPosts = this.posts;
if (idx !== -1) {
// Insert the gap at the appropriate place
let postIdx = currentPosts.indexOf(post);
const origIdx = postIdx;
let headGap = gap.slice(0, this.topic.chunk_size);
let tailGap = gap.slice(this.topic.chunk_size);
stream.splice.apply(stream, [idx, 0].concat(headGap));
if (postIdx !== -1) {
return this.findPostsByIds(headGap).then((posts) => {
posts.forEach((p) => {
const stored = this.storePost(p);
if (!currentPosts.includes(stored)) {
currentPosts.insertAt(postIdx++, stored);
}
});
if (tailGap.length > 0) {
this.get("gaps.before")[postId] = tailGap;
} else {
delete this.get("gaps.before")[postId];
}
this.stream.arrayContentDidChange();
this.postsWithPlaceholders.arrayContentDidChange(
origIdx,
0,
posts.length
);
post.set("hasGap", false);
});
}
}
return Promise.resolve();
},
// Fill in a gap of posts after a particular post
fillGapAfter(post, gap) {
const postId = post.get("id"),
stream = this.stream,
idx = stream.indexOf(postId);
if (idx !== -1) {
stream.pushObjects(gap);
return this.appendMore().then(() => {
delete this.get("gaps.after")[postId];
this.stream.arrayContentDidChange();
});
}
return Promise.resolve();
},
// Appends the next window of posts to the stream. Call it when scrolling downwards.
appendMore() {
// Make sure we can append more posts
if (!this.canAppendMore) {
return Promise.resolve();
}
const postsWithPlaceholders = this.postsWithPlaceholders;
if (this.isMegaTopic) {
this.set("loadingBelow", true);
const fakePostIds = [
...Array(this.get("topic.chunk_size") - 1).keys(),
].map((i) => -i - 1);
postsWithPlaceholders.appending(fakePostIds);
return this.fetchNextWindow(
this.get("posts.lastObject.post_number"),
true,
(p) => {
this.appendPost(p);
}
).finally(() => {
postsWithPlaceholders.finishedAppending(fakePostIds);
this.set("loadingBelow", false);
});
} else {
const postIds = this.nextWindow;
if (isEmpty(postIds)) {
return Promise.resolve();
}
this.set("loadingBelow", true);
postsWithPlaceholders.appending(postIds);
return this.findPostsByIds(postIds)
.then((posts) => {
posts.forEach((p) => this.appendPost(p));
return posts;
})
.finally(() => {
postsWithPlaceholders.finishedAppending(postIds);
this.set("loadingBelow", false);
});
}
},
// Prepend the previous window of posts to the stream. Call it when scrolling upwards.
prependMore() {
// Make sure we can append more posts
if (!this.canPrependMore) {
return Promise.resolve();
}
if (this.isMegaTopic) {
this.set("loadingAbove", true);
let prependedIds = [];
return this.fetchNextWindow(
this.get("posts.firstObject.post_number"),
false,
(p) => {
this.prependPost(p);
prependedIds.push(p.get("id"));
}
).finally(() => {
const postsWithPlaceholders = this.postsWithPlaceholders;
postsWithPlaceholders.finishedPrepending(prependedIds);
this.set("loadingAbove", false);
});
} else {
const postIds = this.previousWindow;
if (isEmpty(postIds)) {
return Promise.resolve();
}
this.set("loadingAbove", true);
return this.findPostsByIds(postIds.reverse())
.then((posts) => {
posts.forEach((p) => this.prependPost(p));
})
.finally(() => {
const postsWithPlaceholders = this.postsWithPlaceholders;
postsWithPlaceholders.finishedPrepending(postIds);
this.set("loadingAbove", false);
});
}
},
/**
Stage a post for insertion in the stream. It should be rendered right away under the
assumption that the post will succeed. We can then `commitPost` when it succeeds or
`undoPost` when it fails.
**/
stagePost(post, user) {
// We can't stage two posts simultaneously
if (this.stagingPost) {
return "alreadyStaging";
}
this.set("stagingPost", true);
const topic = this.topic;
topic.setProperties({
posts_count: (topic.get("posts_count") || 0) + 1,
last_posted_at: new Date(),
"details.last_poster": user,
highest_post_number: (topic.get("highest_post_number") || 0) + 1,
});
post.setProperties({
post_number: topic.get("highest_post_number"),
topic: topic,
created_at: new Date(),
id: -1,
});
// If we're at the end of the stream, add the post
if (this.loadedAllPosts) {
this.appendPost(post);
this.stream.addObject(post.get("id"));
return "staged";
}
return "offScreen";
},
// Commit the post we staged. Call this after a save succeeds.
commitPost(post) {
if (this.get("topic.id") === post.get("topic_id")) {
if (this.loadedAllPosts) {
this.appendPost(post);
this.stream.addObject(post.get("id"));
}
}
this.stream.removeObject(-1);
this._identityMap[-1] = null;
this.set("stagingPost", false);
},
/**
Undo a post we've staged in the stream. Remove it from being rendered and revert the
state we changed.
**/
undoPost(post) {
this.stream.removeObject(-1);
this.postsWithPlaceholders.removePost(() => this.posts.removeObject(post));
this._identityMap[-1] = null;
const topic = this.topic;
this.set("stagingPost", false);
topic.setProperties({
highest_post_number: (topic.get("highest_post_number") || 0) - 1,
posts_count: (topic.get("posts_count") || 0) - 1,
});
// TODO unfudge reply count on parent post
},
prependPost(post) {
const stored = this.storePost(post);
if (stored) {
const posts = this.posts;
posts.unshiftObject(stored);
}
return post;
},
appendPost(post) {
const stored = this.storePost(post);
if (stored) {
const posts = this.posts;
if (!posts.includes(stored)) {
if (!this.loadingBelow) {
this.postsWithPlaceholders.appendPost(() => posts.pushObject(stored));
} else {
posts.pushObject(stored);
}
}
if (stored.get("id") !== -1) {
this.set("lastAppended", stored);
}
}
return post;
},
removePosts(posts) {
if (isEmpty(posts)) {
return;
}
this.postsWithPlaceholders.refreshAll(() => {
const allPosts = this.posts;
const postIds = posts.map((p) => p.get("id"));
const identityMap = this._identityMap;
this.stream.removeObjects(postIds);
allPosts.removeObjects(posts);
postIds.forEach((id) => delete identityMap[id]);
});
},
// Returns a post from the identity map if it's been inserted.
findLoadedPost(id) {
return this._identityMap[id];
},
loadPostByPostNumber(postNumber) {
const url = `/posts/by_number/${this.get("topic.id")}/${postNumber}`;
const store = this.store;
return ajax(url).then((post) => {
return this.storePost(store.createRecord("post", post));
});
},
loadNearestPostToDate(date) {
const url = `/posts/by-date/${this.get("topic.id")}/${date}`;
const store = this.store;
return ajax(url).then((post) => {
return this.storePost(store.createRecord("post", post));
});
},
loadPost(postId) {
const url = "/posts/" + postId;
const store = this.store;
const existing = this._identityMap[postId];
return ajax(url).then((p) => {
if (existing) {
p.cooked = existing.cooked;
}
return this.storePost(store.createRecord("post", p));
});
},
/* mainly for backwards compatability with plugins, used in quick messages plugin
* TODO: remove July 2021
* */
triggerNewPostInStream(postId, opts) {
deprecated(
"Please use triggerNewPostsInStream, this method will be removed July 2021"
);
return this.triggerNewPostsInStream([postId], opts);
},
/**
Finds and adds posts to the stream by id. Typically this would happen if we receive a message
from the message bus indicating there's a new post. We'll only insert it if we currently
have no filters.
**/
triggerNewPostsInStream(postIds, opts) {
const resolved = Promise.resolve();
if (!postIds || postIds.length === 0) {
return resolved;
}
// We only trigger if there are no filters active
if (!this.hasNoFilters) {
return resolved;
}
const loadedAllPosts = this.loadedAllPosts;
this._loadingPostIds = this._loadingPostIds || [];
let missingIds = [];
postIds.forEach((postId) => {
if (postId && this.stream.indexOf(postId) === -1) {
missingIds.push(postId);
}
});
if (missingIds.length === 0) {
return resolved;
}
if (loadedAllPosts) {
missingIds.forEach((postId) => {
if (this._loadingPostIds.indexOf(postId) === -1) {
this._loadingPostIds.push(postId);
}
});
this.set("loadingLastPost", true);
return this.findPostsByIds(this._loadingPostIds, opts)
.then((posts) => {
this._loadingPostIds = null;
const ignoredUsers =
User.current() && User.current().get("ignored_users");
posts.forEach((p) => {
if (ignoredUsers && ignoredUsers.includes(p.username)) {
this.stream.removeObject(p.id);
return;
}
this.stream.addObject(p.id);
this.appendPost(p);
});
})
.finally(() => {
this.set("loadingLastPost", false);
});
} else {
missingIds.forEach((postId) => this.stream.addObject(postId));
}
return resolved;
},
triggerRecoveredPost(postId) {
const existing = this._identityMap[postId];
if (existing) {
return this.triggerChangedPost(postId, new Date());
} else {
// need to insert into stream
const url = `/posts/${postId}`;
const store = this.store;
return ajax(url).then((p) => {
const post = store.createRecord("post", p);
const stream = this.stream;
const posts = this.posts;
this.storePost(post);
// we need to zip this into the stream
let index = 0;
stream.forEach((pid) => {
if (pid < p.id) {
index += 1;
}
});
stream.insertAt(index, p.id);
index = 0;
posts.forEach((_post) => {
if (_post.id < p.id) {
index += 1;
}
});
if (index < posts.length) {
this.postsWithPlaceholders.refreshAll(() => {
posts.insertAt(index, post);
});
} else {
if (post.post_number < posts[posts.length - 1].post_number + 5) {
this.appendMore();
}
}
});
}
},
triggerDeletedPost(postId) {
const existing = this._identityMap[postId];
if (existing && !existing.deleted_at) {
const url = "/posts/" + postId;
const store = this.store;
return ajax(url)
.then((p) => {
this.storePost(store.createRecord("post", p));
})
.catch(() => {
this.removePosts([existing]);
});
}
return Promise.resolve();
},
triggerChangedPost(postId, updatedAt, opts) {
opts = opts || {};
const resolved = Promise.resolve();
if (!postId) {
return resolved;
}
const existing = this._identityMap[postId];
if (existing && existing.updated_at !== updatedAt) {
const url = "/posts/" + postId;
const store = this.store;
return ajax(url).then((p) => {
if (opts.preserveCooked) {
p.cooked = existing.get("cooked");
}
this.storePost(store.createRecord("post", p));
});
}
return resolved;
},
triggerReadPost(postId, readersCount) {
const resolved = Promise.resolve();
resolved.then(() => {
const post = this.findLoadedPost(postId);
if (post && readersCount > post.readers_count) {
post.set("readers_count", readersCount);
this.storePost(post);
}
});
return resolved;
},
postForPostNumber(postNumber) {
if (!this.hasPosts) {
return;
}
return this.posts.find((p) => {
return p.get("post_number") === postNumber;
});
},
/**
Returns the closest post given a postNumber that may not exist in the stream.
For example, if the user asks for a post that's deleted or otherwise outside the range.
This allows us to set the progress bar with the correct number.
**/
closestPostForPostNumber(postNumber) {
if (!this.hasPosts) {
return;
}
let closest = null;
this.posts.forEach((p) => {
if (!closest) {
closest = p;
return;
}
if (
Math.abs(postNumber - p.get("post_number")) <
Math.abs(closest.get("post_number") - postNumber)
) {
closest = p;
}
});
return closest;
},
// Get the index of a post in the stream. (Use this for the topic progress bar.)
progressIndexOfPost(post) {
return this.progressIndexOfPostId(post);
},
// Get the index in the stream of a post id. (Use this for the topic progress bar.)
progressIndexOfPostId(post) {
const postId = post.get("id");
if (this.isMegaTopic) {
return post.get("post_number");
} else {
const index = this.stream.indexOf(postId);
return index + 1;
}
},
/**
Returns the closest post number given a postNumber that may not exist in the stream.
For example, if the user asks for a post that's deleted or otherwise outside the range.
This allows us to set the progress bar with the correct number.
**/
closestPostNumberFor(postNumber) {
if (!this.hasPosts) {
return;
}
let closest = null;
this.posts.forEach((p) => {
if (closest === postNumber) {
return;
}
if (!closest) {
closest = p.get("post_number");
}
if (
Math.abs(postNumber - p.get("post_number")) <
Math.abs(closest - postNumber)
) {
closest = p.get("post_number");
}
});
return closest;
},
closestDaysAgoFor(postNumber) {
const timelineLookup = this.timelineLookup || [];
let low = 0;
let high = timelineLookup.length - 1;
while (low <= high) {
const mid = Math.floor(low + (high - low) / 2);
const midValue = timelineLookup[mid][0];
if (midValue > postNumber) {
high = mid - 1;
} else if (midValue < postNumber) {
low = mid + 1;
} else {
return timelineLookup[mid][1];
}
}
const val = timelineLookup[high] || timelineLookup[low];
if (val) {
return val[1];
}
},
// Find a postId for a postNumber, respecting gaps
findPostIdForPostNumber(postNumber) {
const stream = this.stream,
beforeLookup = this.get("gaps.before"),
streamLength = stream.length;
let sum = 1;
for (let i = 0; i < streamLength; i++) {
const pid = stream[i];
// See if there are posts before this post
if (beforeLookup) {
const before = beforeLookup[pid];
if (before) {
for (let j = 0; j < before.length; j++) {
if (sum === postNumber) {
return pid;
}
sum++;
}
}
}
if (sum === postNumber) {
return pid;
}
sum++;
}
},
updateFromJson(postStreamData) {
const posts = this.posts;
const postsWithPlaceholders = this.postsWithPlaceholders;
postsWithPlaceholders.clear(() => posts.clear());
this.set("gaps", null);
if (postStreamData) {
// Load posts if present
const store = this.store;
postStreamData.posts.forEach((p) =>
this.appendPost(store.createRecord("post", p))
);
delete postStreamData.posts;
// Update our attributes
this.setProperties(postStreamData);
}
},
/**
Stores a post in our identity map, and sets up the references it needs to
find associated objects like the topic. It might return a different reference
than you supplied if the post has already been loaded.
**/
storePost(post) {
// Calling `get(undefined)` raises an error
if (!post) {
return;
}
const postId = get(post, "id");
if (postId) {
const existing = this._identityMap[post.get("id")];
// Update the `highest_post_number` if this post is higher.
const postNumber = post.get("post_number");
if (
postNumber &&
postNumber > (this.get("topic.highest_post_number") || 0)
) {
this.set("topic.highest_post_number", postNumber);
this.set("topic.last_posted_at", post.get("created_at"));
}
if (existing) {
// If the post is in the identity map, update it and return the old reference.
existing.updateFromPost(post);
return existing;
}
post.set("topic", this.topic);
this._identityMap[post.get("id")] = post;
}
return post;
},
fetchNextWindow(postNumber, asc, callback) {
let includeSuggested = !this.get("topic.suggested_topics");
const url = `/t/${this.get("topic.id")}/posts.json`;
let data = {
post_number: postNumber,
asc: asc,
include_suggested: includeSuggested,
};
data = deepMerge(data, this.streamFilters);
const store = this.store;
return ajax(url, { data }).then((result) => {
if (result.suggested_topics) {
this.set("topic.suggested_topics", result.suggested_topics);
}
const posts = get(result, "post_stream.posts");
if (posts) {
posts.forEach((p) => {
p = this.storePost(store.createRecord("post", p));
if (callback) {
callback.call(this, p);
}
});
}
});
},
findPostsByIds(postIds, opts) {
const identityMap = this._identityMap;
const unloaded = postIds.filter((p) => !identityMap[p]);
// Load our unloaded posts by id
return this.loadIntoIdentityMap(unloaded, opts).then(() => {
return postIds.map((p) => identityMap[p]).compact();
});
},
loadIntoIdentityMap(postIds, opts) {
if (isEmpty(postIds)) {
return Promise.resolve([]);
}
let includeSuggested = !this.get("topic.suggested_topics");
const url = "/t/" + this.get("topic.id") + "/posts.json";
const data = { post_ids: postIds, include_suggested: includeSuggested };
const store = this.store;
let headers = {};
if (opts && opts.background) {
headers["Discourse-Background"] = "true";
}
return ajax(url, {
data,
headers,
}).then((result) => {
if (result.suggested_topics) {
this.set("topic.suggested_topics", result.suggested_topics);
}
const posts = get(result, "post_stream.posts");
if (posts) {
posts.forEach((p) => this.storePost(store.createRecord("post", p)));
}
});
},
backfillExcerpts(streamPosition) {
this._excerpts = this._excerpts || [];
const stream = this.stream;
this._excerpts.loadNext = streamPosition;
if (this._excerpts.loading) {
return this._excerpts.loading.then(() => {
if (!this._excerpts[stream[streamPosition]]) {
if (this._excerpts.loadNext === streamPosition) {
return this.backfillExcerpts(streamPosition);
}
}
});
}
let postIds = stream.slice(
Math.max(streamPosition - 20, 0),
streamPosition + 20
);
for (let i = postIds.length - 1; i >= 0; i--) {
if (this._excerpts[postIds[i]]) {
postIds.splice(i, 1);
}
}
let data = {
post_ids: postIds,
};
this._excerpts.loading = ajax(
"/t/" + this.get("topic.id") + "/excerpts.json",
{ data }
)
.then((excerpts) => {
excerpts.forEach((obj) => {
this._excerpts[obj.post_id] = obj;
});
})
.finally(() => {
this._excerpts.loading = null;
});
return this._excerpts.loading;
},
excerpt(streamPosition) {
if (this.isMegaTopic) {
return new Promise((resolve) => resolve(""));
}
const stream = this.stream;
return new Promise((resolve, reject) => {
let excerpt = this._excerpts && this._excerpts[stream[streamPosition]];
if (excerpt) {
resolve(excerpt);
return;
}
this.backfillExcerpts(streamPosition)
.then(() => {
resolve(this._excerpts[stream[streamPosition]]);
})
.catch((e) => reject(e));
});
},
indexOf(post) {
return this.stream.indexOf(post.get("id"));
},
// Handles an error loading a topic based on a HTTP status code. Updates
// the text to the correct values.
errorLoading(result) {
const topic = this.topic;
this.set("loadingFilter", false);
topic.set("errorLoading", true);
const json = result.jqXHR.responseJSON;
if (json && json.extras && json.extras.html) {
topic.set("errorHtml", json.extras.html);
} else {
topic.set("errorMessage", I18n.t("topic.server_error.description"));
topic.set("noRetry", result.jqXHR.status === 403);
}
},
});