From 88a46be051880b6efa47a3c92c553850dad3c80a Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 Nov 2016 11:34:43 +1100 Subject: [PATCH] FEATURE: display text excerpts when scrolling on mobile --- .../discourse/models/post-stream.js.es6 | 57 ++++++++++++++++ .../discourse/widgets/topic-timeline.js.es6 | 68 +++++++++++++++++-- .../stylesheets/common/topic-timeline.scss | 29 +++++++- app/controllers/topics_controller.rb | 34 ++++++++++ config/routes.rb | 1 + spec/controllers/topics_controller_spec.rb | 30 ++++++++ 6 files changed, 213 insertions(+), 6 deletions(-) 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)