From 559fa36c185f31b7883c15ac123d2083c938a96e Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 17 May 2016 13:03:08 -0400 Subject: [PATCH] FEATURE: Topic timeline widget --- .../components/scrolling-post-stream.js.es6 | 27 ++- .../components/topic-progress.js.es6 | 32 +-- .../components/topic-timeline.js.es6 | 17 ++ .../discourse/controllers/topic.js.es6 | 52 +++-- .../discourse/lib/formatter.js.es6 | 2 +- .../discourse/lib/offset-calculator.js.es6 | 8 + .../javascripts/discourse/lib/url.js.es6 | 27 +-- .../discourse/models/post-stream.js.es6 | 25 ++- .../models/topic-tracking-state.js.es6 | 5 + .../javascripts/discourse/templates/topic.hbs | 24 ++- .../discourse/widgets/hooks.js.es6 | 51 ++++- .../discourse/widgets/post-links.js.es6 | 14 +- .../discourse/widgets/topic-timeline.js.es6 | 204 ++++++++++++++++++ .../discourse/widgets/widget.js.es6 | 5 +- app/assets/javascripts/main_include.js | 1 + app/assets/stylesheets/desktop.scss | 1 + app/assets/stylesheets/desktop/discourse.scss | 5 + .../stylesheets/desktop/topic-timeline.scss | 85 ++++++++ app/assets/stylesheets/desktop/topic.scss | 1 + .../post_stream_serializer_mixin.rb | 6 + config/locales/client.en.yml | 4 + lib/timeline_lookup.rb | 26 +++ lib/topic_view.rb | 17 +- spec/components/timeline_lookup_spec.rb | 35 +++ spec/components/topic_view_spec.rb | 4 +- .../models/post-stream-test.js.es6 | 23 ++ .../widgets/post-links-test.js.es6 | 22 +- 27 files changed, 621 insertions(+), 102 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/topic-timeline.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/offset-calculator.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 create mode 100644 app/assets/stylesheets/desktop/topic-timeline.scss create mode 100644 lib/timeline_lookup.rb create mode 100644 spec/components/timeline_lookup_spec.rb diff --git a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 index c5625a810..89f77192b 100644 --- a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 +++ b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 @@ -3,11 +3,12 @@ import { keyDirty } from 'discourse/widgets/widget'; import MountWidget from 'discourse/components/mount-widget'; import { cloak, uncloak } from 'discourse/widgets/post-stream'; import { isWorkaroundActive } from 'discourse/lib/safari-hacks'; +import offsetCalculator from 'discourse/lib/offset-calculator'; function findTopView($posts, viewportTop, min, max) { if (max < min) { return min; } - while(max>min){ + while (max > min) { const mid = Math.floor((min + max) / 2); const $post = $($posts[mid]); const viewBottom = $post.position().top + $post.height(); @@ -26,6 +27,8 @@ export default MountWidget.extend({ widget: 'post-stream', _topVisible: null, _bottomVisible: null, + _currentPost: -1, + _currentVisible: null, args: Ember.computed(function() { return this.getProperties('posts', @@ -66,7 +69,7 @@ export default MountWidget.extend({ const onscreen = []; const nearby = []; - let windowTop = $w.scrollTop(); + const windowTop = $w.scrollTop(); const $posts = this.$('.onscreen-post, .cloaked-post'); const viewportTop = windowTop - slack; @@ -79,6 +82,13 @@ export default MountWidget.extend({ if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; } + let currentPost = -1; + + const offset = offsetCalculator(); + if (windowTop < offset) { + currentPost = 0; + } + let bottomView = topView; while (bottomView < $posts.length) { const post = $posts[bottomView]; @@ -94,6 +104,11 @@ export default MountWidget.extend({ if (viewBottom > windowTop && viewTop <= windowBottom) { onscreen.push(bottomView); } + + if (currentPost === -1 && (viewTop >= windowTop + offset)) { + currentPost = bottomView; + } + nearby.push(bottomView); bottomView++; @@ -131,9 +146,17 @@ export default MountWidget.extend({ this._bottomVisible = last; this.sendAction('bottomVisibleChanged', { post: last, refresh }); } + + if (this._currentPost !== currentPost) { + this._currentPost = currentPost; + const post = posts.objectAt(currentPost); + this.sendAction('currentPostChanged', { post }); + } + } else { this._topVisible = null; this._bottomVisible = null; + this._currentPost = -1; } const onscreenPostNumbers = []; diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index e87f66ebb..a11585b22 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -1,4 +1,3 @@ -import DiscourseURL from 'discourse/lib/url'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ @@ -7,6 +6,7 @@ export default Ember.Component.extend({ expanded: false, toPostIndex: null, docked: false, + progressPosition: null, postStream: Ember.computed.alias('topic.postStream'), @@ -58,7 +58,8 @@ export default Ember.Component.extend({ this.appEvents.on("composer:opened", this, this._dock) .on("composer:resized", this, this._dock) .on("composer:closed", this, this._dock) - .on("topic:scrolled", this, this._dock); + .on("topic:scrolled", this, this._dock) + .on('topic:current-post-changed', postNumber => this.set('progressPosition', postNumber)); // Reflows are expensive. Cache the jQuery selector // and the width when inserted into the DOM @@ -72,7 +73,8 @@ export default Ember.Component.extend({ this.appEvents.off("composer:opened", this, this._dock) .off("composer:resized", this, this._dock) .off("composer:closed", this, this._dock) - .off('topic:scrolled', this, this._dock); + .off('topic:scrolled', this, this._dock) + .off('topic:current-post-changed'); }, _updateProgressBar() { @@ -140,11 +142,6 @@ export default Ember.Component.extend({ } }, - jumpTo(url) { - this.set('expanded', false); - DiscourseURL.routeTo(url); - }, - actions: { toggleExpansion(opts) { this.toggleProperty('expanded'); @@ -173,23 +170,8 @@ export default Ember.Component.extend({ postIndex = this.get('postStream.filteredPostsCount'); } this.set('toPostIndex', postIndex); - const stream = this.get('postStream'); - const postId = stream.findPostIdForPostNumber(postIndex); - - if (!postId) { - Em.Logger.warn("jump-post code broken - requested an index outside the stream array"); - return; - } - - const post = stream.findLoadedPost(postId); - if (post) { - this.jumpTo(this.get('topic').urlForPostNumber(post.get('post_number'))); - } else { - // need to load it - stream.findPostsByIds([postId]).then(arr => { - this.jumpTo(this.get('topic').urlForPostNumber(arr[0].get('post_number'))); - }); - } + this.set('expanded', false); + this.sendAction('jumpToPost', postIndex); }, jumpTop() { diff --git a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 new file mode 100644 index 000000000..2e3250209 --- /dev/null +++ b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 @@ -0,0 +1,17 @@ +import MountWidget from 'discourse/components/mount-widget'; +import computed from 'ember-addons/ember-computed-decorators'; + +export default MountWidget.extend({ + widget: 'topic-timeline', + + @computed('topic') + args(topic) { + return { topic, topicTrackingState: this.topicTrackingState }; + }, + + didInsertElement() { + this._super(); + this.dispatch('topic:current-post-changed', 'timeline-scrollarea'); + } + +}); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 8ddd92d86..53d1ce540 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -21,14 +21,18 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { enteredAt: null, retrying: false, adminMenuVisible: false, - screenProgressPosition: null, showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'), isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"), - @computed('screenProgressPosition', 'model.postStream.filteredPostsCount') - progressPosition(pp, filteredPostsCount) { - return (filteredPostsCount < pp) ? filteredPostsCount : pp; + @computed + showTimeline() { + return !this.site.mobileView; + }, + + @computed('showTimeline') + loadingClass(showTimeline) { + return showTimeline ? 'timeline-loading' : undefined; }, _titleChanged: function() { @@ -182,19 +186,24 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return this.get('model.postStream').fillGapAfter(args.post, args.gap); }, + currentPostChanged(event) { + const { post } = event; + if (!post) { return; } + + const postNumber = post.get('post_number'); + this.set('model.currentPost', postNumber); + this.send('postChangedRoute', postNumber); + this.appEvents.trigger('topic:current-post-changed', postNumber); + }, + // Called the the topmost visible post on the page changes. topVisibleChanged(event) { const { post, refresh } = event; - if (!post) { return; } const postStream = this.get('model.postStream'); const firstLoadedPost = postStream.get('posts.firstObject'); - const currentPostNumber = post.get('post_number'); - this.set('model.currentPost', currentPostNumber); - this.send('postChangedRoute', currentPostNumber); - if (post.get('post_number') === 1) { return; } if (firstLoadedPost && firstLoadedPost === post) { @@ -202,15 +211,13 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, - // Called the the bottommost visible post on the page changes. + // Called the the bottommost visible post on the page changes. bottomVisibleChanged(event) { const { post, refresh } = event; const postStream = this.get('model.postStream'); const lastLoadedPost = postStream.get('posts.lastObject'); - this.set('screenProgressPosition', postStream.progressIndexOfPost(post)); - if (lastLoadedPost && lastLoadedPost === post && postStream.get('canAppendMore')) { postStream.appendMore().then(() => refresh()); // show loading stuff @@ -384,6 +391,27 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, + jumpToPost(postNumber) { + const topic = this.get('model'); + const stream = topic.get('postStream'); + const postId = stream.findPostIdForPostNumber(postNumber); + + if (!postId) { + Em.Logger.warn("jump-post code broken - requested an index outside the stream array"); + return; + } + + const post = stream.findLoadedPost(postId); + if (post) { + DiscourseURL.routeTo(topic.urlForPostNumber(post.get('post_number'))); + } else { + // need to load it + stream.findPostsByIds([postId]).then(arr => { + DiscourseURL.routeTo(topic.urlForPostNumber(arr[0].get('post_number'))); + }); + } + }, + jumpTop() { DiscourseURL.routeTo(this.get('model.firstPostUrl')); }, diff --git a/app/assets/javascripts/discourse/lib/formatter.js.es6 b/app/assets/javascripts/discourse/lib/formatter.js.es6 index 0dfd09fb8..1d5af3376 100644 --- a/app/assets/javascripts/discourse/lib/formatter.js.es6 +++ b/app/assets/javascripts/discourse/lib/formatter.js.es6 @@ -55,7 +55,7 @@ function shortDateNoYear(date) { return moment(date).format(I18n.t("dates.tiny.date_month")); } -function tinyDateYear(date) { +export function tinyDateYear(date) { return moment(date).format(I18n.t("dates.tiny.date_year")); } diff --git a/app/assets/javascripts/discourse/lib/offset-calculator.js.es6 b/app/assets/javascripts/discourse/lib/offset-calculator.js.es6 new file mode 100644 index 000000000..9d8e59f96 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/offset-calculator.js.es6 @@ -0,0 +1,8 @@ +export default function offsetCalculator() { + const $header = $('header'); + const $title = $('#topic-title'); + const windowHeight = $(window).height() - $title.height(); + const expectedOffset = $title.height() - $header.find('.contents').height() + (windowHeight / 5); + + return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset); +} diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index a58011f0f..a2d953a57 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -1,3 +1,5 @@ +import offsetCalculator from 'discourse/lib/offset-calculator'; + /*global LockOn:true*/ let _jumpScheduled = false; const rewrites = []; @@ -14,14 +16,6 @@ const DiscourseURL = Ember.Object.extend({ // Jumps to a particular post in the stream jumpToPost(postNumber, opts) { const holderId = `#post_${postNumber}`; - const offset = () => { - const $header = $('header'); - const $title = $('#topic-title'); - const windowHeight = $(window).height() - $title.height(); - const expectedOffset = $title.height() - $header.find('.contents').height() + (windowHeight / 5); - - return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset); - }; Em.run.schedule('afterRender', () => { if (postNumber === 1) { @@ -29,15 +23,14 @@ const DiscourseURL = Ember.Object.extend({ return; } - const lockon = new LockOn(holderId, {offsetCalculator: offset}); + const lockon = new LockOn(holderId, { offsetCalculator }); const holder = $(holderId); if (holder.length > 0 && opts && opts.skipIfOnScreen){ - // if we are on screen skip - const elementTop = lockon.elementTop(), - scrollTop = $(window).scrollTop(), - windowHeight = $(window).height()-offset(), - height = holder.height(); + const elementTop = lockon.elementTop(); + const scrollTop = $(window).scrollTop(); + const windowHeight = $(window).height() - offsetCalculator(); + const height = holder.height(); if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) { return; @@ -166,11 +159,11 @@ const DiscourseURL = Ember.Object.extend({ return this.handleURL(path, opts); }, - rewrite: function(regexp, replacement) { - rewrites.push({ regexp: regexp, replacement: replacement }); + rewrite(regexp, replacement) { + rewrites.push({ regexp, replacement }); }, - redirectTo: function(url) { + redirectTo(url) { window.location = Discourse.getURL(url); }, diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 75e9ef238..09147836a 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -16,6 +16,7 @@ export default RestModel.extend({ loadingFilter: null, stagingPost: null, postsWithPlaceholders: null, + timelineLookup: null, init() { this._identityMap = {}; @@ -33,6 +34,7 @@ export default RestModel.extend({ loadingBelow: false, loadingFilter: false, stagingPost: false, + timelineLookup: [] }); }, @@ -217,7 +219,7 @@ export default RestModel.extend({ // Request a topicView return loadTopicView(topic, opts).then(json => { this.updateFromJson(json.post_stream); - this.setProperties({ loadingFilter: false, loaded: true }); + this.setProperties({ loadingFilter: false, timelineLookup: json.timeline_lookup, loaded: true }); }).catch(result => { this.errorLoading(result); throw result; @@ -612,6 +614,27 @@ export default RestModel.extend({ return closest; }, + closestDaysAgoFor(postNumber) { + const timelineLookup = this.get('timelineLookup') || []; + + let low = 0, 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.get('stream'), diff --git a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 index 378970893..a3e97c9b0 100644 --- a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 @@ -320,6 +320,11 @@ const TopicTrackingState = Discourse.Model.extend({ .length; }, + lastReadPostNumber(topicId) { + const state = this.states[`t${topicId}`]; + return state ? state.last_read_post_number : null; + }, + countCategory(category_id) { let sum = 0; _.each(this.states, function(topic){ diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 1bc04e13e..e496d88b5 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -71,12 +71,21 @@
- {{topic-progress topic=model - progressPosition=progressPosition - jumpTop="jumpTop" - jumpBottom="jumpBottom"}} - - {{conditional-loading-spinner condition=model.postStream.loadingAbove}} + {{#if showTimeline}} +
+ {{topic-timeline topic=model + jumpTop="jumpTop" + jumpToPost="jumpToPost" + jumpBottom="jumpBottom" + replyToPost="replyToPost"}} +
+ {{else}} + {{topic-progress topic=model + jumpTop="jumpTop" + jumpToPost="jumpToPost" + jumpBottom="jumpBottom"}} + {{/if}} + {{conditional-loading-spinner condition=model.postStream.loadingAbove containerClass=loadingClass}} {{plugin-outlet "topic-above-posts"}} @@ -108,6 +117,7 @@ removeAllowedUser="removeAllowedUser" showInvite="showInvite" topVisibleChanged="topVisibleChanged" + currentPostChanged="currentPostChanged" bottomVisibleChanged="bottomVisibleChanged" selectPost="toggledSelectedPost" selectReplies="toggledSelectedPostReplies" @@ -117,7 +127,7 @@
- {{#conditional-loading-spinner condition=model.postStream.loadingFilter}} + {{#conditional-loading-spinner condition=model.postStream.loadingFilter containerClass=loadingClass}} {{#if loadedAllPosts}} {{topic-closing topic=model}} diff --git a/app/assets/javascripts/discourse/widgets/hooks.js.es6 b/app/assets/javascripts/discourse/widgets/hooks.js.es6 index 669eac77b..b9c8c05f6 100644 --- a/app/assets/javascripts/discourse/widgets/hooks.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hooks.js.es6 @@ -1,8 +1,9 @@ /*eslint no-loop-func:0*/ -const CLICK_ATTRIBUTE_NAME = '_discourse_click_widget'; +const CLICK_ATTRIBUTE_NAME = '_discourse_click_widget'; const CLICK_OUTSIDE_ATTRIBUTE_NAME = '_discourse_click_outside_widget'; -const KEY_UP_ATTRIBUTE_NAME = '_discourse_key_up_widget'; +const KEY_UP_ATTRIBUTE_NAME = '_discourse_key_up_widget'; +const DRAG_ATTRIBUTE_NAME = '_discourse_drag_widget'; function buildHook(attributeName, setAttr) { return class { @@ -29,24 +30,56 @@ function buildHook(attributeName, setAttr) { export const WidgetClickHook = buildHook(CLICK_ATTRIBUTE_NAME); export const WidgetClickOutsideHook = buildHook(CLICK_OUTSIDE_ATTRIBUTE_NAME, 'data-click-outside'); export const WidgetKeyUpHook = buildHook(KEY_UP_ATTRIBUTE_NAME); +export const WidgetDragHook = buildHook(DRAG_ATTRIBUTE_NAME); -function findNode(node, attrName, cb) { + +function nodeCallback(node, attrName, cb) { + const widget = findWidget(node, attrName); + if (widget) { + widget.rerenderResult(() => cb(widget)); + } +} + +function findWidget(node, attrName) { while (node) { const widget = node[attrName]; - if (widget) { - widget.rerenderResult(() => cb(widget)); - break; - } + if (widget) { return widget; } node = node.parentNode; } } let _watchingDocument = false; +let _dragging; + +const DRAG_NAME = "mousemove.discourse-widget-drag"; + +function cancelDrag() { + $('body').removeClass('widget-dragging'); + $(document).off(DRAG_NAME); + + if (_dragging) { + if (_dragging.dragEnd) { _dragging.dragEnd(); } + _dragging = null; + } +} + WidgetClickHook.setupDocumentCallback = function() { if (_watchingDocument) { return; } + $(document).on('mousedown.disource-widget', e => { + cancelDrag(); + const widget = findWidget(e.target, DRAG_ATTRIBUTE_NAME); + if (widget) { + _dragging = widget; + $('body').addClass('widget-dragging'); + $(document).on(DRAG_NAME, dragE => widget.drag(dragE)); + } + }); + + $(document).on('mouseup.discourse-widget-drag', () => cancelDrag()); + $(document).on('click.discourse-widget', e => { - findNode(e.target, CLICK_ATTRIBUTE_NAME, w => w.click(e)); + nodeCallback(e.target, CLICK_ATTRIBUTE_NAME, w => w.click(e)); let node = e.target; const $outside = $('[data-click-outside]'); @@ -60,7 +93,7 @@ WidgetClickHook.setupDocumentCallback = function() { }); $(document).on('keyup.discourse-widget', e => { - findNode(e.target, KEY_UP_ATTRIBUTE_NAME, w => w.keyUp(e)); + nodeCallback(e.target, KEY_UP_ATTRIBUTE_NAME, w => w.keyUp(e)); }); _watchingDocument = true; diff --git a/app/assets/javascripts/discourse/widgets/post-links.js.es6 b/app/assets/javascripts/discourse/widgets/post-links.js.es6 index 45f7c217a..2a6833ce8 100644 --- a/app/assets/javascripts/discourse/widgets/post-links.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-links.js.es6 @@ -48,13 +48,13 @@ export default createWidget('post-links', { }); } - if (attrs.canReplyAsNewTopic) { - result.push(h('li', this.attach('link', { - className: 'reply-new', - contents: () => [I18n.t('post.reply_as_new_topic'), iconNode('plus')], - action: 'newTopicAction' - }))); - } + // if (attrs.canReplyAsNewTopic) { + // result.push(h('li', this.attach('link', { + // className: 'reply-new', + // contents: () => [I18n.t('post.reply_as_new_topic'), iconNode('plus')], + // action: 'newTopicAction' + // }))); + // } return h('ul.post-links', result); }, diff --git a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 new file mode 100644 index 000000000..f8983ec3d --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 @@ -0,0 +1,204 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { shortDate, tinyDateYear } from 'discourse/lib/formatter'; +import { iconNode } from 'discourse/helpers/fa-icon'; + +const SCROLLAREA_HEIGHT = 300; +const SCROLLER_HEIGHT = 50; +const SCROLLAREA_REMAINING = SCROLLAREA_HEIGHT - SCROLLER_HEIGHT; + +createWidget('timeline-last-read', { + tagName: 'div.timeline-last-read', + + buildAttributes(attrs) { + return { style: `height: 40px; top: ${attrs.top}px` }; + }, + + html() { + return [ + iconNode('circle', { class: 'progress' }), + this.attach('button', { + className: 'btn btn-primary btn-small', + icon: 'arrow-left', + label: 'go_back', + action: 'goBack' + }) + ]; + }, + + goBack() { + this.sendWidgetAction('jumpToPost', this.attrs.lastRead); + } +}); + +createWidget('timeline-scroller', { + tagName: 'div.timeline-scroller', + + buildAttributes() { + return { style: `height: ${SCROLLER_HEIGHT}px` }; + }, + + html(attrs) { + const { current, total, date } = attrs; + + const contents = [ + h('div.timeline-replies', I18n.t('topic.timeline.replies', { current, total })) + ]; + + if (date) { + contents.push(h('div.timeline-ago', shortDate(date))); + } + + return [ h('div.timeline-handle'), h('div.timeline-scroller-content', contents) ]; + }, + + drag(e) { + this.sendWidgetAction('updatePercentage', e.pageY); + }, + + dragEnd() { + this.sendWidgetAction('commit'); + } +}); + +createWidget('timeline-padding', { + tagName: 'div.timeline-padding', + buildAttributes(attrs) { + return { style: `height: ${attrs.height}px` }; + }, + + click(e) { + this.sendWidgetAction('updatePercentage', e.pageY); + this.sendWidgetAction('commit'); + } +}); + +createWidget('timeline-scrollarea', { + tagName: 'div.timeline-scrollarea', + buildKey: () => `timeline-scrollarea`, + + buildAttributes() { + return { style: `height: ${SCROLLAREA_HEIGHT}px` }; + }, + + defaultState() { + return { percentage: 0, scrolledPost: 1 }; + }, + + position() { + const { attrs } = this; + const percentage = this.state.percentage; + const postStream = attrs.topic.get('postStream'); + const total = postStream.get('filteredPostsCount'); + let current = Math.round(total * percentage); + + if (current < 1) { current = 1; } + if (current > total) { current = total; } + + const daysAgo = postStream.closestDaysAgoFor(current); + const date = new Date(); + date.setDate(date.getDate() - daysAgo || 0); + + const result = { + current, + total, + date, + lastRead: null, + lastReadPercentage: null + }; + + if (attrs.topicTrackingState) { + const lastRead = attrs.topicTrackingState.lastReadPostNumber(attrs.topic.id); + if (lastRead) { + result.lastRead = lastRead; + result.lastReadPercentage = lastRead === 1 ? 0.0 : parseFloat(lastRead) / total; + } + } + + return result; + }, + + html(attrs, state) { + const position = this.position(); + + state.scrolledPost = position.current; + const percentage = state.percentage; + const before = SCROLLAREA_REMAINING * percentage; + const after = (SCROLLAREA_HEIGHT - before) - SCROLLER_HEIGHT; + + const result = [ + this.attach('timeline-padding', { height: before }), + this.attach('timeline-scroller', position), + this.attach('timeline-padding', { height: after }) + ]; + + + if (position.lastRead) { + const lastReadTop = Math.round(position.lastReadPercentage * SCROLLAREA_HEIGHT); + if (lastReadTop > (before + SCROLLER_HEIGHT)) { + result.push(this.attach('timeline-last-read', { top: lastReadTop, lastRead: position.lastRead })); + } + } + + return result; + }, + + updatePercentage(y) { + const $area = $('.timeline-scrollarea'); + const areaTop = $area.offset().top; + + let percentage = parseFloat(y - areaTop) / $area.height(); + if (percentage > 1.0) { percentage = 1.0; }; + if (percentage < 0.0) { percentage = 0.0; }; + + this.state.percentage = percentage; + }, + + commit() { + const position = this.position(); + this.sendWidgetAction('jumpToPost', position.current); + }, + + topicCurrentPostChanged(postNumber) { + // If the post number didn't change keep our scroll position + if (postNumber !== this.state.scrolledPost) { + const total = this.attrs.topic.get('postStream.filteredPostsCount'); + const perc = postNumber === 1 ? 0.0 : parseFloat(postNumber) / total; + this.state.percentage = perc; + } + } +}); + +export default createWidget('topic-timeline', { + tagName: 'div.topic-timeline', + + html(attrs) { + const { topic } = attrs; + const createdAt = new Date(topic.created_at); + + const controls = []; + if (attrs.topic.get('details.can_create_post')) { + controls.push(this.attach('button', { + className: 'btn btn-primary create', + icon: 'reply', + label: 'topic.reply.title', + action: 'replyToPost' + })); + } + + return [h('div.timeline-controls', controls), + this.attach('link', { + className: 'start-date', + rawLabel: tinyDateYear(createdAt), + action: 'jumpTop' + }), + this.attach('timeline-scrollarea', attrs), + this.attach('link', { + className: 'now-date', + icon: 'dot-circle-o', + label: 'topic.timeline.now', + action: 'jumpBottom' + }) + ]; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6 index 31c1c3fa3..3dc30982a 100644 --- a/app/assets/javascripts/discourse/widgets/widget.js.es6 +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -1,4 +1,4 @@ -import { WidgetClickHook, WidgetClickOutsideHook, WidgetKeyUpHook } from 'discourse/widgets/hooks'; +import { WidgetClickHook, WidgetClickOutsideHook, WidgetKeyUpHook, WidgetDragHook } from 'discourse/widgets/hooks'; import { h } from 'virtual-dom'; import DecoratorHelper from 'discourse/widgets/decorator-helper'; @@ -77,6 +77,9 @@ function drawWidget(builder, attrs, state) { if (this.click) { properties['widget-click'] = new WidgetClickHook(this); } + if (this.drag) { + properties['widget-drag'] = new WidgetDragHook(this); + } const attributes = properties['attributes'] || {}; properties.attributes = attributes; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 7aad77c3c..15c0c299b 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -10,6 +10,7 @@ //= require ./discourse/lib/load-script //= require ./discourse/lib/notification-levels //= require ./discourse/lib/app-events +//= require ./discourse/lib/offset-calculator //= require ./discourse/lib/url //= require ./discourse/lib/debounce //= require ./discourse/lib/quote diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index e3a79437d..fe3df1689 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -12,6 +12,7 @@ @import "desktop/user-card"; @import "desktop/topic-list"; @import "desktop/topic-post"; +@import "desktop/topic-timeline"; @import "desktop/topic"; @import "desktop/upload"; @import "desktop/user"; diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss index 543521bcc..3078f5f7a 100644 --- a/app/assets/stylesheets/desktop/discourse.scss +++ b/app/assets/stylesheets/desktop/discourse.scss @@ -17,6 +17,11 @@ header { margin-bottom: 15px; } +body.widget-dragging { + @include unselectable; + cursor: move; +} + body { .boxed { diff --git a/app/assets/stylesheets/desktop/topic-timeline.scss b/app/assets/stylesheets/desktop/topic-timeline.scss new file mode 100644 index 000000000..5b6fdb2b9 --- /dev/null +++ b/app/assets/stylesheets/desktop/topic-timeline.scss @@ -0,0 +1,85 @@ +.timeline-loading { + width: 900px; +} + +.fixed-gutter { + width: 100%; + box-sizing: border-box; + z-index: 1; + margin-left: 757px; + position: fixed; + top: 140px; + + .topic-timeline { + margin-left: 7em; + width: 200px; + + .timeline-controls { + margin-bottom: 2em; + } + + .start-date { + color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); + } + + .timeline-scrollarea { + margin-top: 0.5em; + margin-left: 0.5em; + border-left: 1px solid; + border-color: dark-light-choose(scale-color($tertiary, $lightness: 80%), scale-color($tertiary, $lightness: 20%)); + position: relative; + } + + .timeline-padding { + transition: height 0.15s ease-out; + cursor: pointer; + .widget-dragging & { + transition: none; + } + } + + .timeline-handle { + @include border-radius-all(0.8em); + width: 0.35em; + background-color: $tertiary; + height: 100%; + float: left; + } + + .timeline-scroller-content { + padding-left: 1em; + } + + .timeline-scroller { + @include unselectable; + margin-left: -0.18em; + cursor: move; + display: flex; + align-items: center; + } + + .timeline-replies { + font-weight: bold; + } + + .timeline-last-read { + position: absolute; + margin-left: -0.18em; + + i.progress { + font-size: 0.5em; + color: $tertiary; + margin-right: 1em; + } + } + + .now-date { + display: inline-block; + color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); + margin-top: 0.5em; + i { + margin-left: 0.15em; + } + } + } +} diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index c91647451..685240c0b 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -84,6 +84,7 @@ border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -75%); padding-top: 10px; height: 20px; + width: 757px; } #topic-progress-wrapper { diff --git a/app/serializers/post_stream_serializer_mixin.rb b/app/serializers/post_stream_serializer_mixin.rb index 55cb5c86d..d87f2feaa 100644 --- a/app/serializers/post_stream_serializer_mixin.rb +++ b/app/serializers/post_stream_serializer_mixin.rb @@ -1,10 +1,12 @@ require_dependency 'gap_serializer' require_dependency 'post_serializer' +require_dependency 'timeline_lookup' module PostStreamSerializerMixin def self.included(klass) klass.attributes :post_stream + klass.attributes :timeline_lookup end def post_stream @@ -13,6 +15,10 @@ module PostStreamSerializerMixin result end + def timeline_lookup + TimelineLookup.build(object.filtered_post_stream) + end + def posts return @posts if @posts.present? @posts = [] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 537595166..92fb67962 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1288,6 +1288,10 @@ en: auto_close_remove: "Don't Auto-Close This Topic" auto_close_immediate: "The last post in the topic is already %{hours} hours old, so the topic will be closed immediately." + timeline: + now: "Now" + replies: "%{current} / %{total} replies" + progress: title: topic progress go_top: "top" diff --git a/lib/timeline_lookup.rb b/lib/timeline_lookup.rb new file mode 100644 index 000000000..4d4c3eac8 --- /dev/null +++ b/lib/timeline_lookup.rb @@ -0,0 +1,26 @@ +module TimelineLookup + + # Given an array of tuples (id, post_number, days_ago), return at most `max_values` worth of a + # lookup table to help the front end timeline display dates associated with posts + def self.build(tuples, max_values=300) + result = [] + + every = (tuples.size.to_f / max_values).ceil + + last_days_ago = -1 + tuples.each_with_index do |t, idx| + next unless (idx % every) === 0 + + _, post_number, days_ago = t + + if (days_ago != last_days_ago) + result << [post_number, days_ago] + last_days_ago = days_ago + end + + end + + result + end + +end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 27d898b4d..74637fa3e 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -311,7 +311,6 @@ class TopicView @filtered_posts.by_newest.with_user.first(25) end - def current_post_ids @current_post_ids ||= if @posts.is_a?(Array) @posts.map {|p| p.id } @@ -320,8 +319,17 @@ class TopicView end end + # Returns an array of [id, post_number, days_ago] tuples. `days_ago` is there for the timeline + # calculations. + def filtered_post_stream + @filtered_post_stream ||= @filtered_posts.order(:sort_order) + .pluck(:id, + :post_number, + 'EXTRACT(DAYS FROM CURRENT_TIMESTAMP - created_at)::INT AS days_ago') + end + def filtered_post_ids - @filtered_post_ids ||= filter_post_ids_by(:sort_order) + @filtered_post_ids ||= filtered_post_stream.map {|tuple| tuple[0]} end protected @@ -435,11 +443,6 @@ class TopicView raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless guardian.can_see?(@topic) end - - def filter_post_ids_by(sort_order) - @filtered_posts.order(sort_order).pluck(:id) - end - def get_minmax_ids(post_number) # Find the closest number we have closest_index = closest_post_to(post_number) diff --git a/spec/components/timeline_lookup_spec.rb b/spec/components/timeline_lookup_spec.rb new file mode 100644 index 000000000..8cb5edc5b --- /dev/null +++ b/spec/components/timeline_lookup_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' +require_dependency 'timeline_lookup' + +describe TimelineLookup do + it "returns an empty array for empty input" do + expect(TimelineLookup.build([])).to eq([]) + end + + it "returns the lookup for a series of posts" do + result = TimelineLookup.build([[111, 1, 10], [222, 2, 9], [333, 3, 8]]) + expect(result).to eq([[1, 10], [2, 9], [3, 8]]) + end + + it "omits duplicate dates" do + result = TimelineLookup.build([[111, 1, 10], [222, 2, 10], [333, 3, 8]]) + expect(result).to eq([[1, 10], [3, 8]]) + end + + it "respects a `max_values` setting" do + input = (1..100).map {|i| [1000+i, i, 100-i] } + + result = TimelineLookup.build(input, 5) + expect(result.size).to eq(5) + expect(result).to eq([[1, 99], [21, 79], [41, 59], [61, 39], [81, 19]]) + end + + it "respects an uneven `max_values` setting" do + input = (1..100).map {|i| [1000+i, i, 100-i] } + + result = TimelineLookup.build(input, 3) + expect(result.size).to eq(3) + expect(result).to eq([[1, 99], [35, 65], [69, 31]]) + end + +end diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index 6cdccf016..b193b9058 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -43,8 +43,8 @@ describe TopicView do let!(:p3) { Fabricate(:post, topic: topic, user: first_poster, percent_rank: 0 )} let(:moderator) { Fabricate(:moderator) } - let(:admin) { Fabricate(:admin) - } + let(:admin) { Fabricate(:admin) } + it "it can find the best responses" do best2 = TopicView.new(topic.id, coding_horror, best: 2) diff --git a/test/javascripts/models/post-stream-test.js.es6 b/test/javascripts/models/post-stream-test.js.es6 index 0b9a7d3df..7fa49fea3 100644 --- a/test/javascripts/models/post-stream-test.js.es6 +++ b/test/javascripts/models/post-stream-test.js.es6 @@ -78,6 +78,29 @@ test('closestPostNumberFor', function() { equal(postStream.closestPostNumberFor(0), 2, "it clips to the lower bound of the stream"); }); +test('closestDaysAgoFor', function() { + const postStream = buildStream(1231); + postStream.set('timelineLookup', [[1, 10], [3, 8], [5, 1]]); + + equal(postStream.closestDaysAgoFor(1), 10); + equal(postStream.closestDaysAgoFor(2), 10); + equal(postStream.closestDaysAgoFor(3), 8); + equal(postStream.closestDaysAgoFor(4), 8); + equal(postStream.closestDaysAgoFor(5), 1); + + // Out of bounds + equal(postStream.closestDaysAgoFor(-1), 10); + equal(postStream.closestDaysAgoFor(0), 10); + equal(postStream.closestDaysAgoFor(10), 1); +}); + +test('closestDaysAgoFor - empty', function() { + const postStream = buildStream(1231); + postStream.set('timelineLookup', []); + + equal(postStream.closestDaysAgoFor(1), null); +}); + test('updateFromJson', function() { const postStream = buildStream(1231); diff --git a/test/javascripts/widgets/post-links-test.js.es6 b/test/javascripts/widgets/post-links-test.js.es6 index b302d20d3..3bda5fab8 100644 --- a/test/javascripts/widgets/post-links-test.js.es6 +++ b/test/javascripts/widgets/post-links-test.js.es6 @@ -46,14 +46,14 @@ widgetTest("collapsed links", { } }); -widgetTest("reply as new topic", { - template: '{{mount-widget widget="post-links" args=args newTopicAction="newTopicAction"}}', - setup() { - this.set('args', { canReplyAsNewTopic: true }); - this.on('newTopicAction', () => this.newTopicTriggered = true); - }, - test(assert) { - click('a.reply-new'); - andThen(() => assert.ok(this.newTopicTriggered)); - } -}); +// widgetTest("reply as new topic", { +// template: '{{mount-widget widget="post-links" args=args newTopicAction="newTopicAction"}}', +// setup() { +// this.set('args', { canReplyAsNewTopic: true }); +// this.on('newTopicAction', () => this.newTopicTriggered = true); +// }, +// test(assert) { +// click('a.reply-new'); +// andThen(() => assert.ok(this.newTopicTriggered)); +// } +// });