diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6
index 2ce7e1076de..7db4ea2ce02 100644
--- a/app/assets/javascripts/discourse/models/post-stream.js.es6
+++ b/app/assets/javascripts/discourse/models/post-stream.js.es6
@@ -728,6 +728,63 @@ export default RestModel.extend({
});
},
+ backfillExcerpts(streamPosition){
+ this._excerpts = this._excerpts || [];
+ const stream = this.get('stream');
+
+ if (this._excerpts.loading) {
+ return this._excerpts.loading.then(()=>{
+ if(!this._excerpts[stream[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){
+
+ const stream = this.get('stream');
+
+ return new Ember.RSVP.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.get('stream').indexOf(post.get('id'));
},
diff --git a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6
index eac9551e16a..927c605c4bd 100644
--- a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6
+++ b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6
@@ -131,6 +131,13 @@ createWidget('timeline-scrollarea', {
result.lastReadPercentage = this._percentFor(topic, idx);
}
+
+ if (this.state.position !== result.current) {
+ this.state.position = result.current;
+ const timeline = this._findAncestorWithProperty('updatePosition');
+ timeline.updatePosition.call(timeline, result.current);
+ }
+
return result;
},
@@ -219,6 +226,47 @@ createWidget('topic-timeline-container', {
export default createWidget('topic-timeline', {
tagName: 'div.topic-timeline',
+ buildKey: () => 'topic-timeline-area',
+
+ defaultState() {
+ return { position: null, excerpt: null };
+ },
+
+ updatePosition(pos) {
+ if (!this.attrs.fullScreen) {
+ return;
+ }
+
+ this.state.position = pos;
+ this.state.excerpt = "";
+ this.scheduleRerender();
+
+ const stream = this.attrs.topic.get('postStream');
+
+ // a little debounce to avoid flashing
+ setTimeout(()=>{
+ if (!this.state.position === pos) {
+ return;
+ }
+
+ stream.excerpt(pos).then(info => {
+
+ if (info && this.state.position === pos) {
+ let excerpt = "";
+
+ if (info.username) {
+ excerpt = "" + info.username + ": ";
+ }
+
+ excerpt += info.excerpt;
+
+ this.state.excerpt = excerpt;
+ this.scheduleRerender();
+ }
+ });
+ }, 50);
+ },
+
html(attrs) {
const { topic } = attrs;
const createdAt = new Date(topic.created_at);
@@ -232,11 +280,21 @@ export default createWidget('topic-timeline', {
if (attrs.mobileView) {
titleHTML = new RawHtml({ html: `${topic.get('fancyTitle')}` });
}
- result.push(h('h3.title', this.attach('link', {
- contents: ()=>titleHTML,
- className: 'fancy-title',
- action: 'jumpTop'
- })));
+
+ let elems = [h('h2', this.attach('link', {
+ contents: ()=>titleHTML,
+ className: 'fancy-title',
+ action: 'jumpTop'}))];
+
+
+ if (this.state.excerpt) {
+ elems.push(
+ new RawHtml({
+ html: "
" + this.state.excerpt + "
"
+ }));
+ }
+
+ result.push(h('div.title', elems));
}
diff --git a/app/assets/stylesheets/common/topic-timeline.scss b/app/assets/stylesheets/common/topic-timeline.scss
index bb74942aa45..2cc8fa4cc4f 100644
--- a/app/assets/stylesheets/common/topic-timeline.scss
+++ b/app/assets/stylesheets/common/topic-timeline.scss
@@ -53,6 +53,7 @@
z-index: 100000;
.topic-timeline {
width: 100%;
+ table-layout: fixed;
margin-left: 0;
margin-right: 0;
display: table;
@@ -61,12 +62,38 @@
text-align: right;
font-size: 1.1em;
}
+ .post-excerpt {
+ max-width: 650px;
+ max-height: 155px;
+ line-height: 1.4em;
+ overflow: hidden;
+ word-wrap: break-word;
+ text-overflow: ellipsis;
+ }
+ .username {
+ color: dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%));
+ word-wrap: break-word;
+ font-weight: bold;
+ }
.title {
margin-top: 0;
padding-left: 1em;
display: table-cell;
vertical-align: top;
- line-height: 1.3em;
+ width: 100%;
+ h2 {
+ margin-top: 0;
+ display: block;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ max-height: 110px;
+ line-height: 1.3em;
+ word-wrap: break-word;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ font-size: 1.3em;
+ }
a {
color: $primary;
}
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 404495c06ff..4961793ffa9 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -186,6 +186,40 @@ class TopicsController < ApplicationController
render_json_dump(TopicViewPostsSerializer.new(@topic_view, scope: guardian, root: false, include_raw: !!params[:include_raw]))
end
+ def excerpts
+ params.require(:topic_id)
+ params.require(:post_ids)
+
+ post_ids = params[:post_ids].map(&:to_i)
+ unless Array === post_ids
+ render_json_error("Expecting post_ids to contain a list of posts ids")
+ return
+ end
+
+ if post_ids.length > 100
+ render_json_error("Requested a chunk that is too big")
+ return
+ end
+
+ @topic = Topic.with_deleted.where(id: params[:topic_id]).first
+ guardian.ensure_can_see!(@topic)
+
+ @posts = Post.where(hidden: false, deleted_at: nil, topic_id: @topic.id)
+ .where('posts.id in (?)', post_ids)
+ .joins("LEFT JOIN users u on u.id = posts.user_id")
+ .pluck(:id, :cooked, :username)
+ .map do |post_id, cooked, username|
+ {
+ post_id: post_id,
+ username: username,
+ excerpt: PrettyText.excerpt(cooked, 800, keep_emoji_images: true)
+ }
+ end
+
+
+ render json: @posts.to_json
+ end
+
def destroy_timings
PostTiming.destroy_for(current_user.id, [params[:topic_id].to_i])
render nothing: true
diff --git a/config/routes.rb b/config/routes.rb
index 35645a8fbc0..06f799640bc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -592,6 +592,7 @@ Discourse::Application.routes.draw do
get "t/:slug/:topic_id/:post_number" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/}
get "t/:slug/:topic_id/last" => "topics#show", post_number: 99999999, constraints: {topic_id: /\d+/}
get "t/:topic_id/posts" => "topics#posts", constraints: {topic_id: /\d+/}, format: :json
+ get "t/:topic_id/excerpts" => "topics#excerpts", constraints: {topic_id: /\d+/}, format: :json
post "t/:topic_id/timings" => "topics#timings", constraints: {topic_id: /\d+/}
post "t/:topic_id/invite" => "topics#invite", constraints: {topic_id: /\d+/}
post "t/:topic_id/invite-group" => "topics#invite_group", constraints: {topic_id: /\d+/}
diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb
index 7ee8aa42b37..05a7b504778 100644
--- a/spec/controllers/topics_controller_spec.rb
+++ b/spec/controllers/topics_controller_spec.rb
@@ -1306,6 +1306,36 @@ describe TopicsController do
end
end
+ context "excerpts" do
+
+ it "can correctly get excerpts" do
+
+ first_post = create_post(raw: 'This is the first post :)', title: 'This is a test title I am making yay')
+ second_post = create_post(raw: 'This is second post', topic: first_post.topic)
+
+ random_post = Fabricate(:post)
+
+
+ xhr :get, :excerpts, topic_id: first_post.topic_id, post_ids: [first_post.id, second_post.id, random_post.id]
+
+ json = JSON.parse(response.body)
+ json.sort!{|a,b| a["post_id"] <=> b["post_id"]}
+
+ # no random post
+ expect(json.length).to eq(2)
+ # keep emoji images
+ expect(json[0]["excerpt"]).to match(/emoji/)
+ expect(json[0]["excerpt"]).to match(/first post/)
+ expect(json[0]["username"]).to eq(first_post.user.username)
+ expect(json[0]["post_id"]).to eq(first_post.id)
+
+ expect(json[1]["excerpt"]).to match(/second post/)
+
+
+ end
+
+ end
+
context "convert_topic" do
it 'needs you to be logged in' do
expect { xhr :put, :convert_topic, id: 111, type: "private" }.to raise_error(Discourse::NotLoggedIn)