FEATURE: Topic timeline widget

This commit is contained in:
Robin Ward 2016-05-17 13:03:08 -04:00
parent 751e354ca6
commit 559fa36c18
No known key found for this signature in database
GPG key ID: 0E091E2B4ED1B83D
27 changed files with 621 additions and 102 deletions

View file

@ -3,11 +3,12 @@ import { keyDirty } from 'discourse/widgets/widget';
import MountWidget from 'discourse/components/mount-widget'; import MountWidget from 'discourse/components/mount-widget';
import { cloak, uncloak } from 'discourse/widgets/post-stream'; import { cloak, uncloak } from 'discourse/widgets/post-stream';
import { isWorkaroundActive } from 'discourse/lib/safari-hacks'; import { isWorkaroundActive } from 'discourse/lib/safari-hacks';
import offsetCalculator from 'discourse/lib/offset-calculator';
function findTopView($posts, viewportTop, min, max) { function findTopView($posts, viewportTop, min, max) {
if (max < min) { return min; } if (max < min) { return min; }
while(max>min){ while (max > min) {
const mid = Math.floor((min + max) / 2); const mid = Math.floor((min + max) / 2);
const $post = $($posts[mid]); const $post = $($posts[mid]);
const viewBottom = $post.position().top + $post.height(); const viewBottom = $post.position().top + $post.height();
@ -26,6 +27,8 @@ export default MountWidget.extend({
widget: 'post-stream', widget: 'post-stream',
_topVisible: null, _topVisible: null,
_bottomVisible: null, _bottomVisible: null,
_currentPost: -1,
_currentVisible: null,
args: Ember.computed(function() { args: Ember.computed(function() {
return this.getProperties('posts', return this.getProperties('posts',
@ -66,7 +69,7 @@ export default MountWidget.extend({
const onscreen = []; const onscreen = [];
const nearby = []; const nearby = [];
let windowTop = $w.scrollTop(); const windowTop = $w.scrollTop();
const $posts = this.$('.onscreen-post, .cloaked-post'); const $posts = this.$('.onscreen-post, .cloaked-post');
const viewportTop = windowTop - slack; const viewportTop = windowTop - slack;
@ -79,6 +82,13 @@ export default MountWidget.extend({
if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } if (windowBottom > bodyHeight) { windowBottom = bodyHeight; }
if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; } if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; }
let currentPost = -1;
const offset = offsetCalculator();
if (windowTop < offset) {
currentPost = 0;
}
let bottomView = topView; let bottomView = topView;
while (bottomView < $posts.length) { while (bottomView < $posts.length) {
const post = $posts[bottomView]; const post = $posts[bottomView];
@ -94,6 +104,11 @@ export default MountWidget.extend({
if (viewBottom > windowTop && viewTop <= windowBottom) { if (viewBottom > windowTop && viewTop <= windowBottom) {
onscreen.push(bottomView); onscreen.push(bottomView);
} }
if (currentPost === -1 && (viewTop >= windowTop + offset)) {
currentPost = bottomView;
}
nearby.push(bottomView); nearby.push(bottomView);
bottomView++; bottomView++;
@ -131,9 +146,17 @@ export default MountWidget.extend({
this._bottomVisible = last; this._bottomVisible = last;
this.sendAction('bottomVisibleChanged', { post: last, refresh }); this.sendAction('bottomVisibleChanged', { post: last, refresh });
} }
if (this._currentPost !== currentPost) {
this._currentPost = currentPost;
const post = posts.objectAt(currentPost);
this.sendAction('currentPostChanged', { post });
}
} else { } else {
this._topVisible = null; this._topVisible = null;
this._bottomVisible = null; this._bottomVisible = null;
this._currentPost = -1;
} }
const onscreenPostNumbers = []; const onscreenPostNumbers = [];

View file

@ -1,4 +1,3 @@
import DiscourseURL from 'discourse/lib/url';
import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({ export default Ember.Component.extend({
@ -7,6 +6,7 @@ export default Ember.Component.extend({
expanded: false, expanded: false,
toPostIndex: null, toPostIndex: null,
docked: false, docked: false,
progressPosition: null,
postStream: Ember.computed.alias('topic.postStream'), postStream: Ember.computed.alias('topic.postStream'),
@ -58,7 +58,8 @@ export default Ember.Component.extend({
this.appEvents.on("composer:opened", this, this._dock) this.appEvents.on("composer:opened", this, this._dock)
.on("composer:resized", this, this._dock) .on("composer:resized", this, this._dock)
.on("composer:closed", 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 // Reflows are expensive. Cache the jQuery selector
// and the width when inserted into the DOM // 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) this.appEvents.off("composer:opened", this, this._dock)
.off("composer:resized", this, this._dock) .off("composer:resized", this, this._dock)
.off("composer:closed", 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() { _updateProgressBar() {
@ -140,11 +142,6 @@ export default Ember.Component.extend({
} }
}, },
jumpTo(url) {
this.set('expanded', false);
DiscourseURL.routeTo(url);
},
actions: { actions: {
toggleExpansion(opts) { toggleExpansion(opts) {
this.toggleProperty('expanded'); this.toggleProperty('expanded');
@ -173,23 +170,8 @@ export default Ember.Component.extend({
postIndex = this.get('postStream.filteredPostsCount'); postIndex = this.get('postStream.filteredPostsCount');
} }
this.set('toPostIndex', postIndex); this.set('toPostIndex', postIndex);
const stream = this.get('postStream'); this.set('expanded', false);
const postId = stream.findPostIdForPostNumber(postIndex); this.sendAction('jumpToPost', 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')));
});
}
}, },
jumpTop() { jumpTop() {

View file

@ -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');
}
});

View file

@ -21,14 +21,18 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
enteredAt: null, enteredAt: null,
retrying: false, retrying: false,
adminMenuVisible: false, adminMenuVisible: false,
screenProgressPosition: null,
showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'), showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'),
isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"), isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"),
@computed('screenProgressPosition', 'model.postStream.filteredPostsCount') @computed
progressPosition(pp, filteredPostsCount) { showTimeline() {
return (filteredPostsCount < pp) ? filteredPostsCount : pp; return !this.site.mobileView;
},
@computed('showTimeline')
loadingClass(showTimeline) {
return showTimeline ? 'timeline-loading' : undefined;
}, },
_titleChanged: function() { _titleChanged: function() {
@ -182,19 +186,24 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return this.get('model.postStream').fillGapAfter(args.post, args.gap); 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. // Called the the topmost visible post on the page changes.
topVisibleChanged(event) { topVisibleChanged(event) {
const { post, refresh } = event; const { post, refresh } = event;
if (!post) { return; } if (!post) { return; }
const postStream = this.get('model.postStream'); const postStream = this.get('model.postStream');
const firstLoadedPost = postStream.get('posts.firstObject'); 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 (post.get('post_number') === 1) { return; }
if (firstLoadedPost && firstLoadedPost === post) { 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) { bottomVisibleChanged(event) {
const { post, refresh } = event; const { post, refresh } = event;
const postStream = this.get('model.postStream'); const postStream = this.get('model.postStream');
const lastLoadedPost = postStream.get('posts.lastObject'); const lastLoadedPost = postStream.get('posts.lastObject');
this.set('screenProgressPosition', postStream.progressIndexOfPost(post));
if (lastLoadedPost && lastLoadedPost === post && postStream.get('canAppendMore')) { if (lastLoadedPost && lastLoadedPost === post && postStream.get('canAppendMore')) {
postStream.appendMore().then(() => refresh()); postStream.appendMore().then(() => refresh());
// show loading stuff // 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() { jumpTop() {
DiscourseURL.routeTo(this.get('model.firstPostUrl')); DiscourseURL.routeTo(this.get('model.firstPostUrl'));
}, },

View file

@ -55,7 +55,7 @@ function shortDateNoYear(date) {
return moment(date).format(I18n.t("dates.tiny.date_month")); 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")); return moment(date).format(I18n.t("dates.tiny.date_year"));
} }

View file

@ -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);
}

View file

@ -1,3 +1,5 @@
import offsetCalculator from 'discourse/lib/offset-calculator';
/*global LockOn:true*/ /*global LockOn:true*/
let _jumpScheduled = false; let _jumpScheduled = false;
const rewrites = []; const rewrites = [];
@ -14,14 +16,6 @@ const DiscourseURL = Ember.Object.extend({
// Jumps to a particular post in the stream // Jumps to a particular post in the stream
jumpToPost(postNumber, opts) { jumpToPost(postNumber, opts) {
const holderId = `#post_${postNumber}`; 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', () => { Em.run.schedule('afterRender', () => {
if (postNumber === 1) { if (postNumber === 1) {
@ -29,15 +23,14 @@ const DiscourseURL = Ember.Object.extend({
return; return;
} }
const lockon = new LockOn(holderId, {offsetCalculator: offset}); const lockon = new LockOn(holderId, { offsetCalculator });
const holder = $(holderId); const holder = $(holderId);
if (holder.length > 0 && opts && opts.skipIfOnScreen){ if (holder.length > 0 && opts && opts.skipIfOnScreen){
// if we are on screen skip const elementTop = lockon.elementTop();
const elementTop = lockon.elementTop(), const scrollTop = $(window).scrollTop();
scrollTop = $(window).scrollTop(), const windowHeight = $(window).height() - offsetCalculator();
windowHeight = $(window).height()-offset(), const height = holder.height();
height = holder.height();
if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) { if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) {
return; return;
@ -166,11 +159,11 @@ const DiscourseURL = Ember.Object.extend({
return this.handleURL(path, opts); return this.handleURL(path, opts);
}, },
rewrite: function(regexp, replacement) { rewrite(regexp, replacement) {
rewrites.push({ regexp: regexp, replacement: replacement }); rewrites.push({ regexp, replacement });
}, },
redirectTo: function(url) { redirectTo(url) {
window.location = Discourse.getURL(url); window.location = Discourse.getURL(url);
}, },

View file

@ -16,6 +16,7 @@ export default RestModel.extend({
loadingFilter: null, loadingFilter: null,
stagingPost: null, stagingPost: null,
postsWithPlaceholders: null, postsWithPlaceholders: null,
timelineLookup: null,
init() { init() {
this._identityMap = {}; this._identityMap = {};
@ -33,6 +34,7 @@ export default RestModel.extend({
loadingBelow: false, loadingBelow: false,
loadingFilter: false, loadingFilter: false,
stagingPost: false, stagingPost: false,
timelineLookup: []
}); });
}, },
@ -217,7 +219,7 @@ export default RestModel.extend({
// Request a topicView // Request a topicView
return loadTopicView(topic, opts).then(json => { return loadTopicView(topic, opts).then(json => {
this.updateFromJson(json.post_stream); this.updateFromJson(json.post_stream);
this.setProperties({ loadingFilter: false, loaded: true }); this.setProperties({ loadingFilter: false, timelineLookup: json.timeline_lookup, loaded: true });
}).catch(result => { }).catch(result => {
this.errorLoading(result); this.errorLoading(result);
throw result; throw result;
@ -612,6 +614,27 @@ export default RestModel.extend({
return closest; 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 // Find a postId for a postNumber, respecting gaps
findPostIdForPostNumber(postNumber) { findPostIdForPostNumber(postNumber) {
const stream = this.get('stream'), const stream = this.get('stream'),

View file

@ -320,6 +320,11 @@ const TopicTrackingState = Discourse.Model.extend({
.length; .length;
}, },
lastReadPostNumber(topicId) {
const state = this.states[`t${topicId}`];
return state ? state.last_read_post_number : null;
},
countCategory(category_id) { countCategory(category_id) {
let sum = 0; let sum = 0;
_.each(this.states, function(topic){ _.each(this.states, function(topic){

View file

@ -71,12 +71,21 @@
<section class="topic-area" id="topic" data-topic-id="{{unbound model.id}}"> <section class="topic-area" id="topic" data-topic-id="{{unbound model.id}}">
<div class="posts-wrapper"> <div class="posts-wrapper">
{{topic-progress topic=model {{#if showTimeline}}
progressPosition=progressPosition <div class='fixed-gutter'>
jumpTop="jumpTop" {{topic-timeline topic=model
jumpBottom="jumpBottom"}} jumpTop="jumpTop"
jumpToPost="jumpToPost"
{{conditional-loading-spinner condition=model.postStream.loadingAbove}} jumpBottom="jumpBottom"
replyToPost="replyToPost"}}
</div>
{{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"}} {{plugin-outlet "topic-above-posts"}}
@ -108,6 +117,7 @@
removeAllowedUser="removeAllowedUser" removeAllowedUser="removeAllowedUser"
showInvite="showInvite" showInvite="showInvite"
topVisibleChanged="topVisibleChanged" topVisibleChanged="topVisibleChanged"
currentPostChanged="currentPostChanged"
bottomVisibleChanged="bottomVisibleChanged" bottomVisibleChanged="bottomVisibleChanged"
selectPost="toggledSelectedPost" selectPost="toggledSelectedPost"
selectReplies="toggledSelectedPostReplies" selectReplies="toggledSelectedPostReplies"
@ -117,7 +127,7 @@
</div> </div>
<div id="topic-bottom"></div> <div id="topic-bottom"></div>
{{#conditional-loading-spinner condition=model.postStream.loadingFilter}} {{#conditional-loading-spinner condition=model.postStream.loadingFilter containerClass=loadingClass}}
{{#if loadedAllPosts}} {{#if loadedAllPosts}}
{{topic-closing topic=model}} {{topic-closing topic=model}}

View file

@ -1,8 +1,9 @@
/*eslint no-loop-func:0*/ /*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 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) { function buildHook(attributeName, setAttr) {
return class { return class {
@ -29,24 +30,56 @@ function buildHook(attributeName, setAttr) {
export const WidgetClickHook = buildHook(CLICK_ATTRIBUTE_NAME); export const WidgetClickHook = buildHook(CLICK_ATTRIBUTE_NAME);
export const WidgetClickOutsideHook = buildHook(CLICK_OUTSIDE_ATTRIBUTE_NAME, 'data-click-outside'); export const WidgetClickOutsideHook = buildHook(CLICK_OUTSIDE_ATTRIBUTE_NAME, 'data-click-outside');
export const WidgetKeyUpHook = buildHook(KEY_UP_ATTRIBUTE_NAME); 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) { while (node) {
const widget = node[attrName]; const widget = node[attrName];
if (widget) { if (widget) { return widget; }
widget.rerenderResult(() => cb(widget));
break;
}
node = node.parentNode; node = node.parentNode;
} }
} }
let _watchingDocument = false; 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() { WidgetClickHook.setupDocumentCallback = function() {
if (_watchingDocument) { return; } 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 => { $(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; let node = e.target;
const $outside = $('[data-click-outside]'); const $outside = $('[data-click-outside]');
@ -60,7 +93,7 @@ WidgetClickHook.setupDocumentCallback = function() {
}); });
$(document).on('keyup.discourse-widget', e => { $(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; _watchingDocument = true;

View file

@ -48,13 +48,13 @@ export default createWidget('post-links', {
}); });
} }
if (attrs.canReplyAsNewTopic) { // if (attrs.canReplyAsNewTopic) {
result.push(h('li', this.attach('link', { // result.push(h('li', this.attach('link', {
className: 'reply-new', // className: 'reply-new',
contents: () => [I18n.t('post.reply_as_new_topic'), iconNode('plus')], // contents: () => [I18n.t('post.reply_as_new_topic'), iconNode('plus')],
action: 'newTopicAction' // action: 'newTopicAction'
}))); // })));
} // }
return h('ul.post-links', result); return h('ul.post-links', result);
}, },

View file

@ -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'
})
];
}
});

View file

@ -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 { h } from 'virtual-dom';
import DecoratorHelper from 'discourse/widgets/decorator-helper'; import DecoratorHelper from 'discourse/widgets/decorator-helper';
@ -77,6 +77,9 @@ function drawWidget(builder, attrs, state) {
if (this.click) { if (this.click) {
properties['widget-click'] = new WidgetClickHook(this); properties['widget-click'] = new WidgetClickHook(this);
} }
if (this.drag) {
properties['widget-drag'] = new WidgetDragHook(this);
}
const attributes = properties['attributes'] || {}; const attributes = properties['attributes'] || {};
properties.attributes = attributes; properties.attributes = attributes;

View file

@ -10,6 +10,7 @@
//= require ./discourse/lib/load-script //= require ./discourse/lib/load-script
//= require ./discourse/lib/notification-levels //= require ./discourse/lib/notification-levels
//= require ./discourse/lib/app-events //= require ./discourse/lib/app-events
//= require ./discourse/lib/offset-calculator
//= require ./discourse/lib/url //= require ./discourse/lib/url
//= require ./discourse/lib/debounce //= require ./discourse/lib/debounce
//= require ./discourse/lib/quote //= require ./discourse/lib/quote

View file

@ -12,6 +12,7 @@
@import "desktop/user-card"; @import "desktop/user-card";
@import "desktop/topic-list"; @import "desktop/topic-list";
@import "desktop/topic-post"; @import "desktop/topic-post";
@import "desktop/topic-timeline";
@import "desktop/topic"; @import "desktop/topic";
@import "desktop/upload"; @import "desktop/upload";
@import "desktop/user"; @import "desktop/user";

View file

@ -17,6 +17,11 @@ header {
margin-bottom: 15px; margin-bottom: 15px;
} }
body.widget-dragging {
@include unselectable;
cursor: move;
}
body { body {
.boxed { .boxed {

View file

@ -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;
}
}
}
}

View file

@ -84,6 +84,7 @@
border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -75%); border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -75%);
padding-top: 10px; padding-top: 10px;
height: 20px; height: 20px;
width: 757px;
} }
#topic-progress-wrapper { #topic-progress-wrapper {

View file

@ -1,10 +1,12 @@
require_dependency 'gap_serializer' require_dependency 'gap_serializer'
require_dependency 'post_serializer' require_dependency 'post_serializer'
require_dependency 'timeline_lookup'
module PostStreamSerializerMixin module PostStreamSerializerMixin
def self.included(klass) def self.included(klass)
klass.attributes :post_stream klass.attributes :post_stream
klass.attributes :timeline_lookup
end end
def post_stream def post_stream
@ -13,6 +15,10 @@ module PostStreamSerializerMixin
result result
end end
def timeline_lookup
TimelineLookup.build(object.filtered_post_stream)
end
def posts def posts
return @posts if @posts.present? return @posts if @posts.present?
@posts = [] @posts = []

View file

@ -1288,6 +1288,10 @@ en:
auto_close_remove: "Don't Auto-Close This Topic" 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." 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: progress:
title: topic progress title: topic progress
go_top: "top" go_top: "top"

26
lib/timeline_lookup.rb Normal file
View file

@ -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

View file

@ -311,7 +311,6 @@ class TopicView
@filtered_posts.by_newest.with_user.first(25) @filtered_posts.by_newest.with_user.first(25)
end end
def current_post_ids def current_post_ids
@current_post_ids ||= if @posts.is_a?(Array) @current_post_ids ||= if @posts.is_a?(Array)
@posts.map {|p| p.id } @posts.map {|p| p.id }
@ -320,8 +319,17 @@ class TopicView
end end
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 def filtered_post_ids
@filtered_post_ids ||= filter_post_ids_by(:sort_order) @filtered_post_ids ||= filtered_post_stream.map {|tuple| tuple[0]}
end end
protected protected
@ -435,11 +443,6 @@ class TopicView
raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless guardian.can_see?(@topic) raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless guardian.can_see?(@topic)
end end
def filter_post_ids_by(sort_order)
@filtered_posts.order(sort_order).pluck(:id)
end
def get_minmax_ids(post_number) def get_minmax_ids(post_number)
# Find the closest number we have # Find the closest number we have
closest_index = closest_post_to(post_number) closest_index = closest_post_to(post_number)

View file

@ -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

View file

@ -43,8 +43,8 @@ describe TopicView do
let!(:p3) { Fabricate(:post, topic: topic, user: first_poster, percent_rank: 0 )} let!(:p3) { Fabricate(:post, topic: topic, user: first_poster, percent_rank: 0 )}
let(:moderator) { Fabricate(:moderator) } let(:moderator) { Fabricate(:moderator) }
let(:admin) { Fabricate(:admin) let(:admin) { Fabricate(:admin) }
}
it "it can find the best responses" do it "it can find the best responses" do
best2 = TopicView.new(topic.id, coding_horror, best: 2) best2 = TopicView.new(topic.id, coding_horror, best: 2)

View file

@ -78,6 +78,29 @@ test('closestPostNumberFor', function() {
equal(postStream.closestPostNumberFor(0), 2, "it clips to the lower bound of the stream"); 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() { test('updateFromJson', function() {
const postStream = buildStream(1231); const postStream = buildStream(1231);

View file

@ -46,14 +46,14 @@ widgetTest("collapsed links", {
} }
}); });
widgetTest("reply as new topic", { // widgetTest("reply as new topic", {
template: '{{mount-widget widget="post-links" args=args newTopicAction="newTopicAction"}}', // template: '{{mount-widget widget="post-links" args=args newTopicAction="newTopicAction"}}',
setup() { // setup() {
this.set('args', { canReplyAsNewTopic: true }); // this.set('args', { canReplyAsNewTopic: true });
this.on('newTopicAction', () => this.newTopicTriggered = true); // this.on('newTopicAction', () => this.newTopicTriggered = true);
}, // },
test(assert) { // test(assert) {
click('a.reply-new'); // click('a.reply-new');
andThen(() => assert.ok(this.newTopicTriggered)); // andThen(() => assert.ok(this.newTopicTriggered));
} // }
}); // });