mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-27 09:36:19 -05:00
FEATURE: Topic timeline widget
This commit is contained in:
parent
751e354ca6
commit
559fa36c18
27 changed files with 621 additions and 102 deletions
|
@ -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 = [];
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
});
|
|
@ -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'));
|
||||
},
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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){
|
||||
|
|
|
@ -71,12 +71,21 @@
|
|||
<section class="topic-area" id="topic" data-topic-id="{{unbound model.id}}">
|
||||
<div class="posts-wrapper">
|
||||
|
||||
{{topic-progress topic=model
|
||||
progressPosition=progressPosition
|
||||
jumpTop="jumpTop"
|
||||
jumpBottom="jumpBottom"}}
|
||||
|
||||
{{conditional-loading-spinner condition=model.postStream.loadingAbove}}
|
||||
{{#if showTimeline}}
|
||||
<div class='fixed-gutter'>
|
||||
{{topic-timeline topic=model
|
||||
jumpTop="jumpTop"
|
||||
jumpToPost="jumpToPost"
|
||||
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"}}
|
||||
|
||||
|
@ -108,6 +117,7 @@
|
|||
removeAllowedUser="removeAllowedUser"
|
||||
showInvite="showInvite"
|
||||
topVisibleChanged="topVisibleChanged"
|
||||
currentPostChanged="currentPostChanged"
|
||||
bottomVisibleChanged="bottomVisibleChanged"
|
||||
selectPost="toggledSelectedPost"
|
||||
selectReplies="toggledSelectedPostReplies"
|
||||
|
@ -117,7 +127,7 @@
|
|||
</div>
|
||||
<div id="topic-bottom"></div>
|
||||
|
||||
{{#conditional-loading-spinner condition=model.postStream.loadingFilter}}
|
||||
{{#conditional-loading-spinner condition=model.postStream.loadingFilter containerClass=loadingClass}}
|
||||
{{#if loadedAllPosts}}
|
||||
|
||||
{{topic-closing topic=model}}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
204
app/assets/javascripts/discourse/widgets/topic-timeline.js.es6
Normal file
204
app/assets/javascripts/discourse/widgets/topic-timeline.js.es6
Normal 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'
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -17,6 +17,11 @@ header {
|
|||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
body.widget-dragging {
|
||||
@include unselectable;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
.boxed {
|
||||
|
|
85
app/assets/stylesheets/desktop/topic-timeline.scss
Normal file
85
app/assets/stylesheets/desktop/topic-timeline.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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"
|
||||
|
|
26
lib/timeline_lookup.rb
Normal file
26
lib/timeline_lookup.rb
Normal 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
|
|
@ -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)
|
||||
|
|
35
spec/components/timeline_lookup_spec.rb
Normal file
35
spec/components/timeline_lookup_spec.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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));
|
||||
// }
|
||||
// });
|
||||
|
|
Loading…
Reference in a new issue