mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-30 10:58:31 -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 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 = [];
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
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'));
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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*/
|
/*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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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){
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
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 { 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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -17,6 +17,11 @@ header {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.widget-dragging {
|
||||||
|
@include unselectable;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
||||||
.boxed {
|
.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%);
|
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 {
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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
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)
|
@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)
|
||||||
|
|
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!(: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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
Loading…
Reference in a new issue