mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-23 15:48:43 -05:00
FEATURE: Use virtual dom framework for faster post rendering
This commit is contained in:
parent
3bf931ce54
commit
d1e85bdd8b
127 changed files with 5724 additions and 2827 deletions
|
@ -90,6 +90,7 @@
|
||||||
"no-undef": 2,
|
"no-undef": 2,
|
||||||
"no-unused-vars": 2,
|
"no-unused-vars": 2,
|
||||||
"no-with": 2,
|
"no-with": 2,
|
||||||
|
"no-this-before-super": 2,
|
||||||
"semi": 2,
|
"semi": 2,
|
||||||
"strict": 0,
|
"strict": 0,
|
||||||
"valid-typeof": 2,
|
"valid-typeof": 2,
|
||||||
|
|
|
@ -152,21 +152,18 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, {
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function proxyDep(propName, moduleFunc, msg) {
|
function RemovedObject(name) {
|
||||||
if (Discourse.hasOwnProperty(propName)) { return; }
|
this._removedName = name;
|
||||||
Object.defineProperty(Discourse, propName, {
|
|
||||||
get: function() {
|
|
||||||
msg = msg || "import the module";
|
|
||||||
Ember.warn("DEPRECATION: `Discourse." + propName + "` is deprecated, " + msg + ".");
|
|
||||||
return moduleFunc();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyDep('computed', function() { return require('discourse/lib/computed'); });
|
function methodMissing() {
|
||||||
proxyDep('Formatter', function() { return require('discourse/lib/formatter'); });
|
console.warn("The " + this._removedName + " object has been removed from Discourse " +
|
||||||
proxyDep('PageTracker', function() { return require('discourse/lib/page-tracker').default; });
|
"and your plugin needs to be updated.");
|
||||||
proxyDep('URL', function() { return require('discourse/lib/url').default; });
|
};
|
||||||
proxyDep('Quote', function() { return require('discourse/lib/quote').default; });
|
|
||||||
proxyDep('debounce', function() { return require('discourse/lib/debounce').default; });
|
['reopen', 'registerButton'].forEach(function(m) { RemovedObject.prototype[m] = methodMissing; });
|
||||||
proxyDep('View', function() { return Ember.View; }, "Use `Ember.View` instead");
|
|
||||||
|
['discourse/views/post', 'discourse/components/post-menu'].forEach(function(moduleName) {
|
||||||
|
define(moduleName, [], function() { return new RemovedObject(moduleName); });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import RestAdapter from 'discourse/adapters/rest';
|
||||||
|
|
||||||
|
export default RestAdapter.extend({
|
||||||
|
find(store, type, findArgs) {
|
||||||
|
const maxReplies = Discourse.SiteSettings.max_reply_history;
|
||||||
|
return Discourse.ajax(`/posts/${findArgs.postId}/reply-history?max_replies=${maxReplies}`).then(replies => {
|
||||||
|
return { post_reply_histories: replies };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,9 @@
|
||||||
|
import RestAdapter from 'discourse/adapters/rest';
|
||||||
|
|
||||||
|
export default RestAdapter.extend({
|
||||||
|
find(store, type, findArgs) {
|
||||||
|
return Discourse.ajax(`/posts/${findArgs.postId}/replies`).then(replies => {
|
||||||
|
return { post_replies: replies };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,122 +0,0 @@
|
||||||
import StringBuffer from 'discourse/mixins/string-buffer';
|
|
||||||
import { iconHTML } from 'discourse/helpers/fa-icon';
|
|
||||||
import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
|
|
||||||
import { on } from 'ember-addons/ember-computed-decorators';
|
|
||||||
|
|
||||||
export default Ember.Component.extend(StringBuffer, {
|
|
||||||
tagName: 'section',
|
|
||||||
classNameBindings: [':post-actions', 'hidden'],
|
|
||||||
actionsSummary: Em.computed.alias('post.actionsWithoutLikes'),
|
|
||||||
emptySummary: Em.computed.empty('actionsSummary'),
|
|
||||||
hidden: Em.computed.and('emptySummary', 'post.notDeleted'),
|
|
||||||
usersByType: null,
|
|
||||||
|
|
||||||
rerenderTriggers: ['actionsSummary.@each', 'post.deleted'],
|
|
||||||
|
|
||||||
@on('init')
|
|
||||||
initUsersByType() {
|
|
||||||
this.set('usersByType', {});
|
|
||||||
},
|
|
||||||
|
|
||||||
// This was creating way too many bound ifs and subviews in the handlebars version.
|
|
||||||
renderString(buffer) {
|
|
||||||
const usersByType = this.get('usersByType');
|
|
||||||
|
|
||||||
if (!this.get('emptySummary')) {
|
|
||||||
this.get('actionsSummary').forEach(function(c) {
|
|
||||||
const id = c.get('id');
|
|
||||||
const users = usersByType[id] || [];
|
|
||||||
|
|
||||||
buffer.push("<div class='post-action'>");
|
|
||||||
|
|
||||||
const renderLink = (dataAttribute, text) => {
|
|
||||||
buffer.push(` <span class='action-link ${dataAttribute}-action'><a href data-${dataAttribute}='${id}'>${text}</a>.</span>`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO multi line expansion for flags
|
|
||||||
let iconsHtml = "";
|
|
||||||
if (users.length) {
|
|
||||||
let postUrl;
|
|
||||||
users.forEach(function(u) {
|
|
||||||
const username = u.get('username');
|
|
||||||
|
|
||||||
iconsHtml += `<a href="${Discourse.getURL("/users")}${username}" data-user-card="${username}">`;
|
|
||||||
if (u.post_url) {
|
|
||||||
postUrl = postUrl || u.post_url;
|
|
||||||
}
|
|
||||||
iconsHtml += Discourse.Utilities.avatarImg({
|
|
||||||
size: 'small',
|
|
||||||
avatarTemplate: u.get('avatar_template'),
|
|
||||||
title: u.get('username')
|
|
||||||
});
|
|
||||||
iconsHtml += "</a>";
|
|
||||||
});
|
|
||||||
|
|
||||||
let key = 'post.actions.people.' + c.get('actionType.name_key');
|
|
||||||
if (postUrl) { key = key + "_with_url"; }
|
|
||||||
|
|
||||||
// TODO postUrl might be uninitialized? pick a good default
|
|
||||||
buffer.push(" " + I18n.t(key, { icons: iconsHtml, postUrl }) + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
renderLink('who-acted', c.get('description'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.get('can_undo')) {
|
|
||||||
renderLink('undo', I18n.t("post.actions.undo." + c.get('actionType.name_key')));
|
|
||||||
}
|
|
||||||
if (c.get('can_defer_flags')) {
|
|
||||||
renderLink('defer-flags', I18n.t("post.actions.defer_flags", { count: c.count }));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
buffer.push("</div>");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = this.get('post');
|
|
||||||
if (post.get('deleted')) {
|
|
||||||
buffer.push("<div class='post-action'>" +
|
|
||||||
iconHTML('fa-trash-o') + ' ' +
|
|
||||||
Discourse.Utilities.tinyAvatar(post.get('postDeletedBy.avatar_template'), {title: post.get('postDeletedBy.username')}) +
|
|
||||||
autoUpdatingRelativeAge(new Date(post.get('postDeletedAt'))) +
|
|
||||||
"</div>");
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.push("<div class='clearfix'></div>");
|
|
||||||
},
|
|
||||||
|
|
||||||
actionTypeById(actionTypeId) {
|
|
||||||
return this.get('actionsSummary').findProperty('id', actionTypeId);
|
|
||||||
},
|
|
||||||
|
|
||||||
click(e) {
|
|
||||||
const $target = $(e.target);
|
|
||||||
let actionTypeId;
|
|
||||||
|
|
||||||
const post = this.get('post');
|
|
||||||
|
|
||||||
if (actionTypeId = $target.data('defer-flags')) {
|
|
||||||
this.actionTypeById(actionTypeId).deferFlags(post);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User wants to know who actioned it
|
|
||||||
const usersByType = this.get('usersByType');
|
|
||||||
if (actionTypeId = $target.data('who-acted')) {
|
|
||||||
this.actionTypeById(actionTypeId).loadUsers(post).then(users => {
|
|
||||||
usersByType[actionTypeId] = users;
|
|
||||||
this.rerender();
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionTypeId = $target.data('undo')) {
|
|
||||||
this.get('actionsSummary').findProperty('id', actionTypeId).undo(post);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -91,6 +91,8 @@ export default Ember.Component.extend({
|
||||||
|
|
||||||
_syncEditorAndPreviewScroll() {
|
_syncEditorAndPreviewScroll() {
|
||||||
const $input = this.$('.d-editor-input');
|
const $input = this.$('.d-editor-input');
|
||||||
|
if (!$input) { return; }
|
||||||
|
|
||||||
const $preview = this.$('.d-editor-preview');
|
const $preview = this.$('.d-editor-preview');
|
||||||
|
|
||||||
if ($input.scrollTop() === 0) {
|
if ($input.scrollTop() === 0) {
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { diff, patch } from 'virtual-dom';
|
||||||
|
import { WidgetClickHook } from 'discourse/widgets/click-hook';
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
_tree: null,
|
||||||
|
_rootNode: null,
|
||||||
|
_timeout: null,
|
||||||
|
_widgetClass: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super();
|
||||||
|
this._widgetClass = this.container.lookupFactory(`widget:${this.get('widget')}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
didInsertElement() {
|
||||||
|
WidgetClickHook.setupDocumentCallback();
|
||||||
|
|
||||||
|
this._rootNode = document.createElement('div');
|
||||||
|
this.element.appendChild(this._rootNode);
|
||||||
|
this._timeout = Ember.run.scheduleOnce('render', this, this.rerenderWidget);
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
Ember.run.cancel(this._timeout);
|
||||||
|
},
|
||||||
|
|
||||||
|
queueRerender() {
|
||||||
|
Ember.run.scheduleOnce('render', this, this.rerenderWidget);
|
||||||
|
},
|
||||||
|
|
||||||
|
rerenderWidget() {
|
||||||
|
Ember.run.cancel(this._timeout);
|
||||||
|
if (this._rootNode) {
|
||||||
|
const t0 = new Date().getTime();
|
||||||
|
|
||||||
|
const opts = { model: this.get('model') };
|
||||||
|
const newTree = new this._widgetClass(this.get('args'), this.container, opts);
|
||||||
|
|
||||||
|
newTree._emberView = this;
|
||||||
|
const patches = diff(this._tree || this._rootNode, newTree);
|
||||||
|
this._rootNode = patch(this._rootNode, patches);
|
||||||
|
this._tree = newTree;
|
||||||
|
console.log('render: ', new Date().getTime() - t0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
|
@ -1,44 +0,0 @@
|
||||||
export default Ember.Component.extend({
|
|
||||||
classNameBindings: [':gap', ':jagged-border', 'gap::hidden'],
|
|
||||||
|
|
||||||
initGaps: function(){
|
|
||||||
this.set('loading', false);
|
|
||||||
const before = this.get('before') === 'true';
|
|
||||||
const gaps = before ? this.get('postStream.gaps.before') : this.get('postStream.gaps.after');
|
|
||||||
|
|
||||||
if (gaps) {
|
|
||||||
this.set('gap', gaps[this.get('post.id')]);
|
|
||||||
}
|
|
||||||
}.on('init'),
|
|
||||||
|
|
||||||
gapsChanged: function(){
|
|
||||||
this.initGaps();
|
|
||||||
this.rerender();
|
|
||||||
}.observes('post.hasGap'),
|
|
||||||
|
|
||||||
render(buffer) {
|
|
||||||
if (this.get('loading')) {
|
|
||||||
buffer.push(I18n.t('loading'));
|
|
||||||
} else {
|
|
||||||
const gapLength = this.get('gap.length');
|
|
||||||
if (gapLength) {
|
|
||||||
buffer.push(I18n.t('post.gap', {count: gapLength}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
click() {
|
|
||||||
if (this.get('loading') || (!this.get('gap'))) { return false; }
|
|
||||||
this.set('loading', true);
|
|
||||||
this.rerender();
|
|
||||||
|
|
||||||
const postStream = this.get('postStream');
|
|
||||||
const filler = this.get('before') === 'true' ? postStream.fillGapBefore : postStream.fillGapAfter;
|
|
||||||
|
|
||||||
filler.call(postStream, this.get('post'), this.get('gap')).then(() => {
|
|
||||||
this.set('gap', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,85 +0,0 @@
|
||||||
const MAX_SHOWN = 5;
|
|
||||||
|
|
||||||
import StringBuffer from 'discourse/mixins/string-buffer';
|
|
||||||
import { iconHTML } from 'discourse/helpers/fa-icon';
|
|
||||||
import computed from 'ember-addons/ember-computed-decorators';
|
|
||||||
|
|
||||||
const { get, isEmpty, Component } = Ember;
|
|
||||||
|
|
||||||
export default Component.extend(StringBuffer, {
|
|
||||||
classNameBindings: [':gutter'],
|
|
||||||
|
|
||||||
rerenderTriggers: ['expanded'],
|
|
||||||
|
|
||||||
// Roll up links to avoid duplicates
|
|
||||||
@computed('links')
|
|
||||||
collapsed(links) {
|
|
||||||
const seen = {};
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
if (!isEmpty(links)) {
|
|
||||||
links.forEach(function(l) {
|
|
||||||
const title = get(l, 'title');
|
|
||||||
if (!seen[title]) {
|
|
||||||
result.pushObject(l);
|
|
||||||
seen[title] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderString(buffer) {
|
|
||||||
const links = this.get('collapsed');
|
|
||||||
const collapsed = !this.get('expanded');
|
|
||||||
|
|
||||||
if (!isEmpty(links)) {
|
|
||||||
let toRender = links;
|
|
||||||
if (collapsed) {
|
|
||||||
toRender = toRender.slice(0, MAX_SHOWN);
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.push("<ul class='post-links'>");
|
|
||||||
toRender.forEach(function(l) {
|
|
||||||
const direction = get(l, 'reflection') ? 'inbound' : 'outbound',
|
|
||||||
clicks = get(l, 'clicks');
|
|
||||||
|
|
||||||
buffer.push(`<li><a href='${get(l, 'url')}' class='track-link ${direction}'>`);
|
|
||||||
|
|
||||||
let title = get(l, 'title');
|
|
||||||
if (!isEmpty(title)) {
|
|
||||||
title = Discourse.Utilities.escapeExpression(title);
|
|
||||||
buffer.push(Discourse.Emoji.unescape(title));
|
|
||||||
}
|
|
||||||
if (clicks) {
|
|
||||||
buffer.push(`<span class='badge badge-notification clicks'>${clicks}</span>`);
|
|
||||||
}
|
|
||||||
buffer.push("</a></li>");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
const remaining = links.length - MAX_SHOWN;
|
|
||||||
if (remaining > 0) {
|
|
||||||
buffer.push(`<li><a href class='toggle-more'>${I18n.t('post.more_links', {count: remaining})}</a></li>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buffer.push('</ul>');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.get('canReplyAsNewTopic')) {
|
|
||||||
buffer.push(`<a href class='reply-new'>${iconHTML('plus')}${I18n.t('post.reply_as_new_topic')}</a>`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
click(e) {
|
|
||||||
const $target = $(e.target);
|
|
||||||
if ($target.hasClass('toggle-more')) {
|
|
||||||
this.toggleProperty('expanded');
|
|
||||||
return false;
|
|
||||||
} else if ($target.closest('.reply-new').length) {
|
|
||||||
this.sendAction('newTopicAction', this.get('post'));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,440 +0,0 @@
|
||||||
import StringBuffer from 'discourse/mixins/string-buffer';
|
|
||||||
import { iconHTML } from 'discourse/helpers/fa-icon';
|
|
||||||
|
|
||||||
// Helper class for rendering a button
|
|
||||||
export const Button = function(action, label, icon, opts) {
|
|
||||||
this.action = action;
|
|
||||||
this.label = label;
|
|
||||||
|
|
||||||
if (typeof icon === "object") {
|
|
||||||
this.opts = icon;
|
|
||||||
} else {
|
|
||||||
this.icon = icon;
|
|
||||||
}
|
|
||||||
this.opts = this.opts || opts || {};
|
|
||||||
};
|
|
||||||
|
|
||||||
function animateHeart($elem, start, end, complete) {
|
|
||||||
if (Ember.testing) { return Ember.run(this, complete); }
|
|
||||||
|
|
||||||
$elem.stop()
|
|
||||||
.css('textIndent', start)
|
|
||||||
.animate({ textIndent: end }, {
|
|
||||||
complete,
|
|
||||||
step(now) {
|
|
||||||
$(this).css('transform','scale('+now+')');
|
|
||||||
},
|
|
||||||
duration: 150
|
|
||||||
}, 'linear');
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.prototype.render = function(buffer) {
|
|
||||||
const opts = this.opts;
|
|
||||||
|
|
||||||
const label = I18n.t(this.label, opts.labelOptions);
|
|
||||||
if (opts.prefixHTML) {
|
|
||||||
buffer.push(opts.prefixHTML);
|
|
||||||
}
|
|
||||||
buffer.push("<button aria-label=\"" + label +"\" " + "title=\"" + label + "\"");
|
|
||||||
|
|
||||||
if (opts.disabled) { buffer.push(" disabled"); }
|
|
||||||
if (opts.className) { buffer.push(" class=\"" + opts.className + "\""); }
|
|
||||||
if (opts.shareUrl) { buffer.push(" data-share-url=\"" + opts.shareUrl + "\""); }
|
|
||||||
if (opts.postNumber) { buffer.push(" data-post-number=\"" + opts.postNumber + "\""); }
|
|
||||||
buffer.push(" data-action=\"" + this.action + "\">");
|
|
||||||
if (this.icon) { buffer.push(iconHTML(this.icon)); }
|
|
||||||
if (opts.textLabel) { buffer.push(I18n.t(opts.textLabel)); }
|
|
||||||
if (opts.innerHTML) { buffer.push(opts.innerHTML); }
|
|
||||||
buffer.push("</button>");
|
|
||||||
};
|
|
||||||
|
|
||||||
let hiddenButtons;
|
|
||||||
|
|
||||||
const PostMenuComponent = Ember.Component.extend(StringBuffer, {
|
|
||||||
tagName: 'section',
|
|
||||||
classNames: ['post-menu-area', 'clearfix'],
|
|
||||||
|
|
||||||
rerenderTriggers: [
|
|
||||||
'post.deleted_at',
|
|
||||||
'post.likeAction.count',
|
|
||||||
'post.likeAction.users.length',
|
|
||||||
'post.reply_count',
|
|
||||||
'post.showRepliesBelow',
|
|
||||||
'post.can_delete',
|
|
||||||
'post.bookmarked',
|
|
||||||
'post.shareUrl',
|
|
||||||
'post.topic.deleted_at',
|
|
||||||
'post.replies.length',
|
|
||||||
'post.wiki',
|
|
||||||
'post.post_type',
|
|
||||||
'collapsed'],
|
|
||||||
|
|
||||||
_collapsedByDefault: function() {
|
|
||||||
this.set('collapsed', true);
|
|
||||||
}.on('init'),
|
|
||||||
|
|
||||||
renderString(buffer) {
|
|
||||||
const post = this.get('post');
|
|
||||||
|
|
||||||
buffer.push("<nav class='post-controls'>");
|
|
||||||
this.renderReplies(post, buffer);
|
|
||||||
this.renderButtons(post, buffer);
|
|
||||||
this.renderAdminPopup(post, buffer);
|
|
||||||
buffer.push("</nav>");
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delegate click actions
|
|
||||||
click(e) {
|
|
||||||
const $target = $(e.target);
|
|
||||||
const action = $target.data('action') || $target.parent().data('action');
|
|
||||||
|
|
||||||
if ($target.prop('disabled') || $target.parent().prop('disabled')) { return; }
|
|
||||||
|
|
||||||
if (!action) return;
|
|
||||||
const handler = this["click" + action.classify()];
|
|
||||||
if (!handler) return;
|
|
||||||
|
|
||||||
handler.call(this, this.get('post'));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Replies Button
|
|
||||||
renderReplies(post, buffer) {
|
|
||||||
if (!post.get('showRepliesBelow')) return;
|
|
||||||
|
|
||||||
const replyCount = post.get('reply_count');
|
|
||||||
buffer.push("<button class='show-replies highlight-action' data-action='replies'>");
|
|
||||||
buffer.push(I18n.t("post.has_replies", { count: replyCount || 0 }));
|
|
||||||
|
|
||||||
const icon = (this.get('post.replies.length') > 0) ? 'chevron-up' : 'chevron-down';
|
|
||||||
return buffer.push(iconHTML(icon) + "</button>");
|
|
||||||
},
|
|
||||||
|
|
||||||
renderButtons(post, buffer) {
|
|
||||||
const self = this;
|
|
||||||
const allButtons = [];
|
|
||||||
let visibleButtons = [];
|
|
||||||
|
|
||||||
if (typeof hiddenButtons === "undefined") {
|
|
||||||
if (!Em.isEmpty(this.siteSettings.post_menu_hidden_items)) {
|
|
||||||
hiddenButtons = this.siteSettings.post_menu_hidden_items.split('|');
|
|
||||||
} else {
|
|
||||||
hiddenButtons = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (post.get("bookmarked")) {
|
|
||||||
hiddenButtons.removeObject("bookmark");
|
|
||||||
}
|
|
||||||
|
|
||||||
const yours = post.get('yours');
|
|
||||||
this.siteSettings.post_menu.split("|").forEach(function(i) {
|
|
||||||
const creator = self["buttonFor" + i.classify()];
|
|
||||||
if (creator) {
|
|
||||||
const button = creator.call(self, post);
|
|
||||||
if (button) {
|
|
||||||
allButtons.push(button);
|
|
||||||
if ((yours && button.opts.alwaysShowYours) ||
|
|
||||||
(post.get('wiki') && button.opts.alwaysShowWiki) ||
|
|
||||||
(hiddenButtons.indexOf(i) === -1)) {
|
|
||||||
visibleButtons.push(button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only show ellipsis if there is more than one button hidden
|
|
||||||
// if there are no more buttons, we are not collapsed
|
|
||||||
const collapsed = this.get('collapsed');
|
|
||||||
if (!collapsed || (allButtons.length <= visibleButtons.length + 1)) {
|
|
||||||
visibleButtons = allButtons;
|
|
||||||
if (collapsed) { this.set('collapsed', false); }
|
|
||||||
} else {
|
|
||||||
visibleButtons.splice(visibleButtons.length - 1, 0, this.buttonForShowMoreActions(post));
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbacks = PostMenuComponent._registerButtonCallbacks;
|
|
||||||
if (callbacks) {
|
|
||||||
_.each(callbacks, function(callback) {
|
|
||||||
callback.apply(self, [visibleButtons]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.push('<div class="actions">');
|
|
||||||
visibleButtons.forEach((b) => b.render(buffer));
|
|
||||||
buffer.push("</div>");
|
|
||||||
},
|
|
||||||
|
|
||||||
clickLikeCount() {
|
|
||||||
this.sendActionTarget('toggleWhoLiked');
|
|
||||||
},
|
|
||||||
|
|
||||||
sendActionTarget(action, arg) {
|
|
||||||
const target = this.get(`${action}Target`);
|
|
||||||
return target ? target.send(this.get(action), arg) : this.sendAction(action, arg);
|
|
||||||
},
|
|
||||||
|
|
||||||
clickReplies() {
|
|
||||||
if (this.get('post.replies.length') > 0) {
|
|
||||||
this.set('post.replies', []);
|
|
||||||
} else {
|
|
||||||
this.get('post').loadReplies();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete button
|
|
||||||
buttonForDelete(post) {
|
|
||||||
let label, icon;
|
|
||||||
|
|
||||||
if (post.get('post_number') === 1) {
|
|
||||||
// If it's the first post, the delete/undo actions are related to the topic
|
|
||||||
const topic = post.get('topic');
|
|
||||||
if (topic.get('deleted_at')) {
|
|
||||||
if (!topic.get('details.can_recover')) { return; }
|
|
||||||
label = "topic.actions.recover";
|
|
||||||
icon = "undo";
|
|
||||||
} else {
|
|
||||||
if (!topic.get('details.can_delete')) { return; }
|
|
||||||
label = "topic.actions.delete";
|
|
||||||
icon = "trash-o";
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// The delete actions target the post iteself
|
|
||||||
if (post.get('deleted_at') || post.get('user_deleted')) {
|
|
||||||
if (!post.get('can_recover')) { return; }
|
|
||||||
label = "post.controls.undelete";
|
|
||||||
icon = "undo";
|
|
||||||
} else {
|
|
||||||
if (!post.get('can_delete')) { return; }
|
|
||||||
label = "post.controls.delete";
|
|
||||||
icon = "trash-o";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const action = (icon === 'trash-o') ? 'delete' : 'recover';
|
|
||||||
let opts;
|
|
||||||
if (icon === "trash-o"){
|
|
||||||
opts = {className: 'delete'};
|
|
||||||
}
|
|
||||||
return new Button(action, label, icon, opts);
|
|
||||||
},
|
|
||||||
|
|
||||||
clickRecover(post) {
|
|
||||||
this.sendAction('recoverPost', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
clickDelete(post) {
|
|
||||||
this.sendAction('deletePost', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Like button
|
|
||||||
buttonForLike() {
|
|
||||||
const likeAction = this.get('post.likeAction');
|
|
||||||
if (!likeAction) { return; }
|
|
||||||
|
|
||||||
const className = likeAction.get('acted') ? 'has-like fade-out' : 'like';
|
|
||||||
const opts = {className: className};
|
|
||||||
|
|
||||||
if (likeAction.get('canToggle')) {
|
|
||||||
const descKey = likeAction.get('acted') ? 'post.controls.undo_like' : 'post.controls.like';
|
|
||||||
return new Button('like', descKey, 'heart', opts);
|
|
||||||
} else if (likeAction.get('acted')) {
|
|
||||||
opts.disabled = true;
|
|
||||||
return new Button('like', 'post.controls.has_liked', 'heart', opts);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
buttonForLikeCount() {
|
|
||||||
const likeCount = this.get('post.likeAction.count') || 0;
|
|
||||||
if (likeCount > 0) {
|
|
||||||
const likedPost = !!this.get('post.likeAction.acted');
|
|
||||||
|
|
||||||
const label = likedPost
|
|
||||||
? likeCount === 1 ? 'post.has_likes_title_only_you' : 'post.has_likes_title_you'
|
|
||||||
: 'post.has_likes_title';
|
|
||||||
|
|
||||||
return new Button('like-count', label, undefined, {
|
|
||||||
className: 'like-count highlight-action',
|
|
||||||
innerHTML: I18n.t("post.has_likes", { count: likeCount }),
|
|
||||||
labelOptions: {count: likedPost ? (likeCount-1) : likeCount}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clickLike(post) {
|
|
||||||
const $heart = this.$('.fa-heart'),
|
|
||||||
$likeButton = this.$('button[data-action=like]'),
|
|
||||||
acted = post.get('likeAction.acted'),
|
|
||||||
self = this;
|
|
||||||
|
|
||||||
if (acted) {
|
|
||||||
this.sendActionTarget('toggleLike');
|
|
||||||
$likeButton.removeClass('has-like').addClass('like');
|
|
||||||
} else {
|
|
||||||
const scale = [1.0, 1.5];
|
|
||||||
animateHeart($heart, scale[0], scale[1], function() {
|
|
||||||
animateHeart($heart, scale[1], scale[0], function() {
|
|
||||||
self.sendActionTarget('toggleLike');
|
|
||||||
$likeButton.removeClass('like').addClass('has-like');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Flag button
|
|
||||||
buttonForFlag(post) {
|
|
||||||
if (Em.isEmpty(post.get('flagsAvailable'))) return;
|
|
||||||
return new Button('flag', 'post.controls.flag', 'flag');
|
|
||||||
},
|
|
||||||
|
|
||||||
clickFlag(post) {
|
|
||||||
this.sendAction('showFlags', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Edit button
|
|
||||||
buttonForEdit(post) {
|
|
||||||
if (!post.get('can_edit')) return;
|
|
||||||
return new Button('edit', 'post.controls.edit', 'pencil', {
|
|
||||||
alwaysShowYours: true,
|
|
||||||
alwaysShowWiki: true
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clickEdit(post) {
|
|
||||||
this.sendAction('editPost', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Share button
|
|
||||||
buttonForShare(post) {
|
|
||||||
const options = {
|
|
||||||
shareUrl: post.get('shareUrl'),
|
|
||||||
postNumber: post.get('post_number')
|
|
||||||
};
|
|
||||||
return new Button('share', 'post.controls.share', 'link', options);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Reply button
|
|
||||||
buttonForReply() {
|
|
||||||
if (!this.get('canCreatePost')) return;
|
|
||||||
const options = {className: 'create fade-out'};
|
|
||||||
|
|
||||||
if(!Discourse.Mobile.mobileView) {
|
|
||||||
options.textLabel = 'topic.reply.title';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Button('reply', 'post.controls.reply', 'reply', options);
|
|
||||||
},
|
|
||||||
|
|
||||||
clickReply(post) {
|
|
||||||
this.sendAction('replyToPost', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Bookmark button
|
|
||||||
buttonForBookmark(post) {
|
|
||||||
if (!Discourse.User.current()) return;
|
|
||||||
|
|
||||||
let iconClass = 'read-icon',
|
|
||||||
buttonClass = 'bookmark',
|
|
||||||
tooltip = 'bookmarks.not_bookmarked';
|
|
||||||
|
|
||||||
if (post.get('bookmarked')) {
|
|
||||||
iconClass += ' bookmarked';
|
|
||||||
buttonClass += ' bookmarked';
|
|
||||||
tooltip = 'bookmarks.created';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Button('bookmark', tooltip, {className: buttonClass, innerHTML: "<div class='" + iconClass + "'>"});
|
|
||||||
},
|
|
||||||
|
|
||||||
clickBookmark(post) {
|
|
||||||
this.sendAction('toggleBookmark', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Wiki button
|
|
||||||
buttonForWiki(post) {
|
|
||||||
if (!post.get('can_wiki')) return;
|
|
||||||
|
|
||||||
if (post.get('wiki')) {
|
|
||||||
return new Button('wiki', 'post.controls.unwiki', 'pencil-square-o', {className: 'wiki wikied'});
|
|
||||||
} else {
|
|
||||||
return new Button('wiki', 'post.controls.wiki', 'pencil-square-o', {className: 'wiki'});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clickWiki(post) {
|
|
||||||
this.sendAction('toggleWiki', post);
|
|
||||||
},
|
|
||||||
|
|
||||||
buttonForAdmin() {
|
|
||||||
if (!Discourse.User.currentProp('canManageTopic')) { return; }
|
|
||||||
return new Button('admin', 'post.controls.admin', 'wrench');
|
|
||||||
},
|
|
||||||
|
|
||||||
renderAdminPopup(post, buffer) {
|
|
||||||
if (!Discourse.User.currentProp('canManageTopic')) { return; }
|
|
||||||
|
|
||||||
const isModerator = post.get('post_type') === this.site.get('post_types.moderator_action'),
|
|
||||||
postTypeIcon = iconHTML('shield'),
|
|
||||||
postTypeText = isModerator ? I18n.t('post.controls.revert_to_regular') : I18n.t('post.controls.convert_to_moderator'),
|
|
||||||
rebakePostIcon = iconHTML('cog'),
|
|
||||||
rebakePostText = I18n.t('post.controls.rebake'),
|
|
||||||
unhidePostIcon = iconHTML('eye'),
|
|
||||||
unhidePostText = I18n.t('post.controls.unhide'),
|
|
||||||
changePostOwnerIcon = iconHTML('user'),
|
|
||||||
changePostOwnerText = I18n.t('post.controls.change_owner');
|
|
||||||
|
|
||||||
const html = '<div class="post-admin-menu popup-menu">' +
|
|
||||||
'<h3>' + I18n.t('admin_title') + '</h3>' +
|
|
||||||
'<ul>' +
|
|
||||||
(Discourse.User.currentProp('staff') ? '<li class="btn" data-action="togglePostType">' + postTypeIcon + postTypeText + '</li>' : '') +
|
|
||||||
'<li class="btn" data-action="rebakePost">' + rebakePostIcon + rebakePostText + '</li>' +
|
|
||||||
(post.hidden ? '<li class="btn" data-action="unhidePost">' + unhidePostIcon + unhidePostText + '</li>' : '') +
|
|
||||||
(Discourse.User.currentProp('admin') ? '<li class="btn" data-action="changePostOwner">' + changePostOwnerIcon + changePostOwnerText + '</li>' : '') +
|
|
||||||
'</ul>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
buffer.push(html);
|
|
||||||
},
|
|
||||||
|
|
||||||
clickAdmin() {
|
|
||||||
const $postAdminMenu = this.$(".post-admin-menu");
|
|
||||||
$postAdminMenu.show();
|
|
||||||
$("html").on("mouseup.post-admin-menu", function() {
|
|
||||||
$postAdminMenu.hide();
|
|
||||||
$("html").off("mouseup.post-admin-menu");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clickTogglePostType() {
|
|
||||||
this.sendAction("togglePostType", this.get("post"));
|
|
||||||
},
|
|
||||||
|
|
||||||
clickRebakePost() {
|
|
||||||
this.sendAction("rebakePost", this.get("post"));
|
|
||||||
},
|
|
||||||
|
|
||||||
clickUnhidePost() {
|
|
||||||
this.sendAction("unhidePost", this.get("post"));
|
|
||||||
},
|
|
||||||
|
|
||||||
clickChangePostOwner() {
|
|
||||||
this.sendAction("changePostOwner", this.get("post"));
|
|
||||||
},
|
|
||||||
|
|
||||||
buttonForShowMoreActions() {
|
|
||||||
return new Button('showMoreActions', 'show_more', 'ellipsis-h');
|
|
||||||
},
|
|
||||||
|
|
||||||
clickShowMoreActions() {
|
|
||||||
this.set('collapsed', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
PostMenuComponent.reopenClass({
|
|
||||||
registerButton(callback){
|
|
||||||
this._registerButtonCallbacks = this._registerButtonCallbacks || [];
|
|
||||||
this._registerButtonCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PostMenuComponent;
|
|
|
@ -1,77 +0,0 @@
|
||||||
import { setting } from 'discourse/lib/computed';
|
|
||||||
|
|
||||||
const PosterNameComponent = Em.Component.extend({
|
|
||||||
classNames: ['names', 'trigger-user-card'],
|
|
||||||
displayNameOnPosts: setting('display_name_on_posts'),
|
|
||||||
|
|
||||||
// sanitize name for comparison
|
|
||||||
sanitizeName(name){
|
|
||||||
return name.toLowerCase().replace(/[\s_-]/g,'');
|
|
||||||
},
|
|
||||||
|
|
||||||
render(buffer) {
|
|
||||||
const post = this.get('post');
|
|
||||||
|
|
||||||
if (post) {
|
|
||||||
const username = post.get('username'),
|
|
||||||
primaryGroupName = post.get('primary_group_name'),
|
|
||||||
url = post.get('usernameUrl');
|
|
||||||
|
|
||||||
var linkClass = 'username',
|
|
||||||
name = post.get('name');
|
|
||||||
|
|
||||||
if (post.get('staff')) { linkClass += ' staff'; }
|
|
||||||
if (post.get('admin')) { linkClass += ' admin'; }
|
|
||||||
if (post.get('moderator')) { linkClass += ' moderator'; }
|
|
||||||
if (post.get('new_user')) { linkClass += ' new-user'; }
|
|
||||||
|
|
||||||
if (!Em.isEmpty(primaryGroupName)) {
|
|
||||||
linkClass += ' ' + primaryGroupName;
|
|
||||||
}
|
|
||||||
// Main link
|
|
||||||
buffer.push("<span class='" + linkClass + "'><a href='" + url + "' data-auto-route='true' data-user-card='" + username + "'>" + username + "</a>");
|
|
||||||
|
|
||||||
// Add a glyph if we have one
|
|
||||||
const glyph = this.posterGlyph(post);
|
|
||||||
if (!Em.isEmpty(glyph)) {
|
|
||||||
buffer.push(glyph);
|
|
||||||
}
|
|
||||||
buffer.push("</span>");
|
|
||||||
|
|
||||||
// Are we showing full names?
|
|
||||||
if (name && this.get('displayNameOnPosts') && (this.sanitizeName(name) !== this.sanitizeName(username))) {
|
|
||||||
name = Discourse.Utilities.escapeExpression(name);
|
|
||||||
buffer.push("<span class='full-name'><a href='" + url + "' data-auto-route='true' data-user-card='" + username + "'>" + name + "</a></span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// User titles
|
|
||||||
let title = post.get('user_title');
|
|
||||||
if (!Em.isEmpty(title)) {
|
|
||||||
|
|
||||||
title = Discourse.Utilities.escapeExpression(title);
|
|
||||||
buffer.push('<span class="user-title">');
|
|
||||||
if (Em.isEmpty(primaryGroupName)) {
|
|
||||||
buffer.push(title);
|
|
||||||
} else {
|
|
||||||
buffer.push("<a href='/groups/" + post.get('primary_group_name') + "' class='user-group'>" + title + "</a>");
|
|
||||||
}
|
|
||||||
buffer.push("</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
PosterNameComponent.trigger('renderedName', buffer, post);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Overwrite this to give a user a custom font awesome glyph.
|
|
||||||
posterGlyph(post) {
|
|
||||||
if(post.get('moderator')) {
|
|
||||||
const desc = I18n.t('user.moderator_tooltip');
|
|
||||||
return '<i class="fa fa-shield" title="' + desc + '" alt="' + desc + '"></i>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Support for event triggering
|
|
||||||
PosterNameComponent.reopenClass(Em.Evented);
|
|
||||||
|
|
||||||
export default PosterNameComponent;
|
|
|
@ -1,27 +0,0 @@
|
||||||
export default Ember.Component.extend({
|
|
||||||
layoutName: 'components/private-message-map',
|
|
||||||
tagName: 'section',
|
|
||||||
classNames: ['information'],
|
|
||||||
details: Em.computed.alias('topic.details'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
removeAllowedUser: function(user) {
|
|
||||||
var self = this;
|
|
||||||
bootbox.dialog(I18n.t("private_message_info.remove_allowed_user", {name: user.get('username')}), [
|
|
||||||
{label: I18n.t("no_value"),
|
|
||||||
'class': 'btn-danger right'},
|
|
||||||
{label: I18n.t("yes_value"),
|
|
||||||
'class': 'btn-primary',
|
|
||||||
callback: function() {
|
|
||||||
self.get('topic.details').removeAllowedUser(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
showPrivateInvite: function() {
|
|
||||||
this.sendAction('showPrivateInviteAction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { keyDirty } from 'discourse/widgets/widget';
|
||||||
|
import MountWidget from 'discourse/components/mount-widget';
|
||||||
|
|
||||||
|
function findTopView($posts, viewportTop, min, max) {
|
||||||
|
if (max < min) { return min; }
|
||||||
|
|
||||||
|
while(max>min){
|
||||||
|
const mid = Math.floor((min + max) / 2);
|
||||||
|
const $post = $($posts[mid]);
|
||||||
|
const viewBottom = $post.position().top + $post.height();
|
||||||
|
|
||||||
|
if (viewBottom > viewportTop) {
|
||||||
|
max = mid-1;
|
||||||
|
} else {
|
||||||
|
min = mid+1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MountWidget.extend({
|
||||||
|
widget: 'post-stream',
|
||||||
|
_topVisible: null,
|
||||||
|
_bottomVisible: null,
|
||||||
|
|
||||||
|
args: Ember.computed(function() {
|
||||||
|
return this.getProperties('posts',
|
||||||
|
'canCreatePost',
|
||||||
|
'multiSelect',
|
||||||
|
'selectedQuery',
|
||||||
|
'selectedPostsCount',
|
||||||
|
'searchService');
|
||||||
|
}).volatile(),
|
||||||
|
|
||||||
|
scrolled() {
|
||||||
|
const $w = $(window);
|
||||||
|
const windowHeight = window.innerHeight ? window.innerHeight : $w.height();
|
||||||
|
const slack = Math.round(windowHeight * 15);
|
||||||
|
const onscreen = [];
|
||||||
|
|
||||||
|
let windowTop = $w.scrollTop();
|
||||||
|
|
||||||
|
const $posts = this.$('article.boxed');
|
||||||
|
const viewportTop = windowTop - slack;
|
||||||
|
const topView = findTopView($posts, viewportTop, 0, $posts.length-1);
|
||||||
|
|
||||||
|
let windowBottom = windowTop + windowHeight;
|
||||||
|
let viewportBottom = windowBottom + slack;
|
||||||
|
|
||||||
|
const bodyHeight = $('body').height();
|
||||||
|
if (windowBottom > bodyHeight) { windowBottom = bodyHeight; }
|
||||||
|
if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; }
|
||||||
|
|
||||||
|
let bottomView = topView;
|
||||||
|
while (bottomView < $posts.length) {
|
||||||
|
const post = $posts[bottomView];
|
||||||
|
const $post = $(post);
|
||||||
|
|
||||||
|
if (!$post) { break; }
|
||||||
|
|
||||||
|
const viewTop = $post.offset().top;
|
||||||
|
const viewBottom = viewTop + $post.height();
|
||||||
|
|
||||||
|
if (viewTop > viewportBottom) { break; }
|
||||||
|
|
||||||
|
if (viewBottom > windowTop && viewTop <= windowBottom) {
|
||||||
|
onscreen.push(bottomView);
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomView++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = this.posts;
|
||||||
|
if (onscreen.length) {
|
||||||
|
|
||||||
|
const refresh = () => this.queueRerender();
|
||||||
|
const first = posts.objectAt(onscreen[0]);
|
||||||
|
if (this._topVisible !== first) {
|
||||||
|
this._topVisible = first;
|
||||||
|
const $body = $('body');
|
||||||
|
const elem = $posts[onscreen[0]];
|
||||||
|
const elemId = elem.id;
|
||||||
|
const $elem = $(elem);
|
||||||
|
const elemPos = $elem.position();
|
||||||
|
const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0;
|
||||||
|
|
||||||
|
const topRefresh = () => {
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
Ember.run.next(() => {
|
||||||
|
const $refreshedElem = $(elemId);
|
||||||
|
|
||||||
|
// Quickly going back might mean the element is destroyed
|
||||||
|
const position = $refreshedElem.position();
|
||||||
|
if (position && position.top) {
|
||||||
|
$('html, body').scrollTop(position.top + distToElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.sendAction('topVisibleChanged', { post: first, refresh: topRefresh });
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = posts.objectAt(onscreen[onscreen.length-1]);
|
||||||
|
if (this._bottomVisible !== last) {
|
||||||
|
this._bottomVisible = last;
|
||||||
|
this.sendAction('bottomVisibleChanged', { post: last, refresh });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._topVisible = null;
|
||||||
|
this._bottomVisible = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onscreenPostNumbers = onscreen.map(idx => posts.objectAt(idx).post_number);
|
||||||
|
this.screenTrack.setOnscreen(onscreenPostNumbers);
|
||||||
|
},
|
||||||
|
|
||||||
|
_scrollTriggered() {
|
||||||
|
Ember.run.scheduleOnce('afterRender', this, this.scrolled);
|
||||||
|
},
|
||||||
|
|
||||||
|
didInsertElement() {
|
||||||
|
this._super();
|
||||||
|
const debouncedScroll = () => Ember.run.debounce(this, this._scrollTriggered, 10);
|
||||||
|
|
||||||
|
$(document).bind('touchmove.post-stream', debouncedScroll);
|
||||||
|
$(window).bind('scroll.post-stream', debouncedScroll);
|
||||||
|
this._scrollTriggered();
|
||||||
|
|
||||||
|
this.appEvents.on('post-stream:refresh', postId => {
|
||||||
|
if (postId) {
|
||||||
|
keyDirty(`post-${postId}`);
|
||||||
|
}
|
||||||
|
this.queueRerender();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
this._super();
|
||||||
|
$(document).unbind('touchmove.post-stream');
|
||||||
|
$(window).unbind('scroll.post-stream');
|
||||||
|
this.appEvents.off('post-stream:refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
|
@ -1,33 +1,17 @@
|
||||||
import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
|
import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
|
||||||
import computed from 'ember-addons/ember-computed-decorators';
|
|
||||||
|
|
||||||
const icons = {
|
export function actionDescriptionHtml(actionCode, createdAt, username) {
|
||||||
'closed.enabled': 'lock',
|
const dt = new Date(createdAt);
|
||||||
'closed.disabled': 'unlock-alt',
|
const when = autoUpdatingRelativeAge(dt, { format: 'medium-with-ago' });
|
||||||
'autoclosed.enabled': 'lock',
|
const who = username ? `<a class="mention" href="/users/${username}">@${username}</a>` : "";
|
||||||
'autoclosed.disabled': 'unlock-alt',
|
return I18n.t(`action_codes.${actionCode}`, { who, when }).htmlSafe();
|
||||||
'archived.enabled': 'folder',
|
}
|
||||||
'archived.disabled': 'folder-open',
|
|
||||||
'pinned.enabled': 'thumb-tack',
|
|
||||||
'pinned.disabled': 'thumb-tack unpinned',
|
|
||||||
'pinned_globally.enabled': 'thumb-tack',
|
|
||||||
'pinned_globally.disabled': 'thumb-tack unpinned',
|
|
||||||
'visible.enabled': 'eye',
|
|
||||||
'visible.disabled': 'eye-slash',
|
|
||||||
'split_topic': 'sign-out',
|
|
||||||
'invited_user': 'plus-circle',
|
|
||||||
'removed_user': 'minus-circle'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function actionDescription(actionCode, createdAt, username) {
|
export function actionDescription(actionCode, createdAt, username) {
|
||||||
return function() {
|
return function() {
|
||||||
const ac = this.get(actionCode);
|
const ac = this.get(actionCode);
|
||||||
if (ac) {
|
if (ac) {
|
||||||
const dt = new Date(this.get(createdAt));
|
return actionDescriptionHtml(ac, this.get(createdAt), this.get(username));
|
||||||
const when = autoUpdatingRelativeAge(dt, { format: 'medium-with-ago' });
|
|
||||||
const u = this.get(username);
|
|
||||||
const who = u ? `<a class="mention" href="/users/${u}">@${u}</a>` : "";
|
|
||||||
return I18n.t(`action_codes.${ac}`, { who, when }).htmlSafe();
|
|
||||||
}
|
}
|
||||||
}.property(actionCode, createdAt);
|
}.property(actionCode, createdAt);
|
||||||
}
|
}
|
||||||
|
@ -38,11 +22,6 @@ export default Ember.Component.extend({
|
||||||
|
|
||||||
description: actionDescription('actionCode', 'post.created_at', 'post.action_code_who'),
|
description: actionDescription('actionCode', 'post.created_at', 'post.action_code_who'),
|
||||||
|
|
||||||
@computed("actionCode")
|
|
||||||
icon(actionCode) {
|
|
||||||
return icons[actionCode] || 'exclamation';
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
edit() {
|
edit() {
|
||||||
this.sendAction('editPost', this.get('post'));
|
this.sendAction('editPost', this.get('post'));
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import SmallActionComponent from 'discourse/components/small-action';
|
|
||||||
|
|
||||||
export default SmallActionComponent.extend({
|
|
||||||
classNames: ['time-gap'],
|
|
||||||
classNameBindings: ['hideTimeGap::hidden'],
|
|
||||||
hideTimeGap: Em.computed.alias('postStream.hasNoFilters'),
|
|
||||||
icon: 'clock-o',
|
|
||||||
|
|
||||||
description: function() {
|
|
||||||
const gapDays = this.get('daysAgo');
|
|
||||||
if (gapDays < 30) {
|
|
||||||
return I18n.t('dates.later.x_days', {count: gapDays});
|
|
||||||
} else if (gapDays < 365) {
|
|
||||||
const gapMonths = Math.floor(gapDays / 30);
|
|
||||||
return I18n.t('dates.later.x_months', {count: gapMonths});
|
|
||||||
} else {
|
|
||||||
const gapYears = Math.floor(gapDays / 365);
|
|
||||||
return I18n.t('dates.later.x_years', {count: gapYears});
|
|
||||||
}
|
|
||||||
}.property(),
|
|
||||||
});
|
|
|
@ -1,12 +0,0 @@
|
||||||
export default Ember.Component.extend({
|
|
||||||
layoutName: 'components/toggle-summary',
|
|
||||||
tagName: 'section',
|
|
||||||
classNames: ['information'],
|
|
||||||
postStream: Em.computed.alias('topic.postStream'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
toggleSummary() {
|
|
||||||
this.get('postStream').toggleSummary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,46 +0,0 @@
|
||||||
var LINKS_SHOWN = 5;
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
mapCollapsed: true,
|
|
||||||
layoutName: 'components/topic-map',
|
|
||||||
details: Em.computed.alias('topic.details'),
|
|
||||||
allLinksShown: false,
|
|
||||||
|
|
||||||
init: function() {
|
|
||||||
this._super();
|
|
||||||
|
|
||||||
// If the topic has a summary, expand the map by default
|
|
||||||
this.set('mapCollapsed', Discourse.Mobile.mobileView || (!this.get('topic.has_summary')));
|
|
||||||
},
|
|
||||||
|
|
||||||
showPosterAvatar: Em.computed.gt('topic.posts_count', 2),
|
|
||||||
|
|
||||||
toggleMapClass: function() {
|
|
||||||
return this.get('mapCollapsed') ? 'chevron-down' : 'chevron-up';
|
|
||||||
}.property('mapCollapsed'),
|
|
||||||
|
|
||||||
showAllLinksControls: function() {
|
|
||||||
if (this.get('allLinksShown')) return false;
|
|
||||||
if ((this.get('details.links.length') || 0) <= LINKS_SHOWN) return false;
|
|
||||||
return true;
|
|
||||||
}.property('allLinksShown', 'topic.details.links'),
|
|
||||||
|
|
||||||
infoLinks: function() {
|
|
||||||
var allLinks = this.get('details.links');
|
|
||||||
if (Em.isNone(allLinks)) return [];
|
|
||||||
|
|
||||||
if (this.get('allLinksShown')) return allLinks;
|
|
||||||
return allLinks.slice(0, LINKS_SHOWN);
|
|
||||||
|
|
||||||
}.property('details.links', 'allLinksShown'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
toggleMap: function() {
|
|
||||||
this.toggleProperty('mapCollapsed');
|
|
||||||
},
|
|
||||||
|
|
||||||
showAllLinks: function() {
|
|
||||||
this.set('allLinksShown', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,18 +0,0 @@
|
||||||
export default Ember.Component.extend({
|
|
||||||
|
|
||||||
postStream: Em.computed.alias('participant.topic.postStream'),
|
|
||||||
showPostCount: Em.computed.gte('participant.post_count', 2),
|
|
||||||
|
|
||||||
toggled: function() {
|
|
||||||
return this.get('postStream.userFilters').contains(this.get('participant.username'));
|
|
||||||
}.property('postStream.userFilters.[]'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
toggle() {
|
|
||||||
const postStream = this.get('postStream');
|
|
||||||
if (postStream) {
|
|
||||||
postStream.toggleParticipant(this.get('participant.username'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,26 +0,0 @@
|
||||||
import StringBuffer from 'discourse/mixins/string-buffer';
|
|
||||||
|
|
||||||
export default Ember.Component.extend(StringBuffer, {
|
|
||||||
rerenderTriggers: ['users.length'],
|
|
||||||
|
|
||||||
renderString(buffer) {
|
|
||||||
const users = this.get('users');
|
|
||||||
if (users && users.get('length') > 0) {
|
|
||||||
buffer.push("<div class='who-liked'>");
|
|
||||||
let iconsHtml = "";
|
|
||||||
users.forEach(function(u) {
|
|
||||||
iconsHtml += "<a href=\"" + Discourse.getURL("/users/") + u.get('username_lower') + "\" data-user-card=\"" + u.get('username') + "\">";
|
|
||||||
iconsHtml += Discourse.Utilities.avatarImg({
|
|
||||||
size: 'small',
|
|
||||||
avatarTemplate: u.get('avatar_template'),
|
|
||||||
title: u.get('username')
|
|
||||||
});
|
|
||||||
iconsHtml += "</a>";
|
|
||||||
});
|
|
||||||
buffer.push(I18n.t('post.actions.people.like',{icons: iconsHtml}));
|
|
||||||
buffer.push("</div>");
|
|
||||||
} else {
|
|
||||||
buffer.push("<span></span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -289,6 +289,7 @@ export default Ember.Controller.extend({
|
||||||
self.destroyDraft();
|
self.destroyDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.appEvents.trigger('post-stream:refresh');
|
||||||
self.close();
|
self.close();
|
||||||
|
|
||||||
const currentUser = Discourse.User.current();
|
const currentUser = Discourse.User.current();
|
||||||
|
@ -587,14 +588,6 @@ export default Ember.Controller.extend({
|
||||||
$('.d-editor-input').autocomplete({ cancel: true });
|
$('.d-editor-input').autocomplete({ cancel: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
showOptions() {
|
|
||||||
var _ref;
|
|
||||||
return (_ref = this.get('controllers.modal')) ? _ref.show(Discourse.ArchetypeOptionsModalView.create({
|
|
||||||
archetype: this.get('model.archetype'),
|
|
||||||
metaData: this.get('model.metaData')
|
|
||||||
})) : void 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
canEdit: function() {
|
canEdit: function() {
|
||||||
return this.get("model.action") === "edit" && Discourse.User.current().get("can_edit");
|
return this.get("model.action") === "edit" && Discourse.User.current().get("can_edit");
|
||||||
}.property("model.action"),
|
}.property("model.action"),
|
||||||
|
|
|
@ -19,7 +19,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
loadedAllPosts: Em.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
|
loadedAllPosts: Em.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
|
||||||
enteredAt: null,
|
enteredAt: null,
|
||||||
retrying: false,
|
retrying: false,
|
||||||
firstPostExpanded: false,
|
|
||||||
adminMenuVisible: false,
|
adminMenuVisible: false,
|
||||||
|
|
||||||
showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'),
|
showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'),
|
||||||
|
@ -100,7 +99,60 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
DiscourseURL.routeTo(url);
|
DiscourseURL.routeTo(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectedQuery: function() {
|
||||||
|
return post => this.postSelected(post);
|
||||||
|
}.property(),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
|
fillGapBefore(args) {
|
||||||
|
return this.get('model.postStream').fillGapBefore(args.post, args.gap);
|
||||||
|
},
|
||||||
|
|
||||||
|
fillGapAfter(args) {
|
||||||
|
return this.get('model.postStream').fillGapAfter(args.post, args.gap);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
this.set('model.currentPost', post.get('post_number'));
|
||||||
|
|
||||||
|
if (post.get('post_number') === 1) { return; }
|
||||||
|
|
||||||
|
if (firstLoadedPost && firstLoadedPost === post) {
|
||||||
|
postStream.prependMore().then(() => refresh());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post));
|
||||||
|
|
||||||
|
if (lastLoadedPost && lastLoadedPost === post) {
|
||||||
|
postStream.appendMore().then(() => refresh());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSummary() {
|
||||||
|
return this.get('model.postStream').toggleSummary();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAllowedUser(user) {
|
||||||
|
return this.get('model.details').removeAllowedUser(user);
|
||||||
|
},
|
||||||
|
|
||||||
showTopicAdminMenu() {
|
showTopicAdminMenu() {
|
||||||
this.set('adminMenuVisible', true);
|
this.set('adminMenuVisible', true);
|
||||||
},
|
},
|
||||||
|
@ -113,7 +165,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
this.deleteTopic();
|
this.deleteTopic();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
archiveMessage() {
|
archiveMessage() {
|
||||||
const topic = this.get('model');
|
const topic = this.get('model');
|
||||||
topic.archiveMessage().then(()=>{
|
topic.archiveMessage().then(()=>{
|
||||||
|
@ -176,8 +227,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
|
|
||||||
// Deleting the first post deletes the topic
|
// Deleting the first post deletes the topic
|
||||||
if (post.get('post_number') === 1) {
|
if (post.get('post_number') === 1) {
|
||||||
this.deleteTopic();
|
return this.deleteTopic();
|
||||||
return;
|
|
||||||
} else if (!post.can_delete) {
|
} else if (!post.can_delete) {
|
||||||
// check if current user can delete post
|
// check if current user can delete post
|
||||||
return false;
|
return false;
|
||||||
|
@ -210,7 +260,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
post.destroy(user).catch(function(error) {
|
return post.destroy(user).then(() => {
|
||||||
|
this.appEvents.trigger('post-stream:refresh');
|
||||||
|
}).catch(error => {
|
||||||
popupAjaxError(error);
|
popupAjaxError(error);
|
||||||
post.undoDeleteState();
|
post.undoDeleteState();
|
||||||
});
|
});
|
||||||
|
@ -245,7 +297,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleBookmark(post) {
|
toggleBookmark(post) {
|
||||||
if (!Discourse.User.current()) {
|
if (!this.currentUser) {
|
||||||
alert(I18n.t("bookmarks.not_bookmarked"));
|
alert(I18n.t("bookmarks.not_bookmarked"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -261,18 +313,20 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
},
|
},
|
||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
const posts = this.get('model.postStream.posts'),
|
const posts = this.get('model.postStream.posts');
|
||||||
selectedPosts = this.get('selectedPosts');
|
const selectedPosts = this.get('selectedPosts');
|
||||||
if (posts) {
|
if (posts) {
|
||||||
selectedPosts.addObjects(posts);
|
selectedPosts.addObjects(posts);
|
||||||
}
|
}
|
||||||
this.set('allPostsSelected', true);
|
this.set('allPostsSelected', true);
|
||||||
|
this.appEvents.trigger('post-stream:refresh');
|
||||||
},
|
},
|
||||||
|
|
||||||
deselectAll() {
|
deselectAll() {
|
||||||
this.get('selectedPosts').clear();
|
this.get('selectedPosts').clear();
|
||||||
this.get('selectedReplies').clear();
|
this.get('selectedReplies').clear();
|
||||||
this.set('allPostsSelected', false);
|
this.set('allPostsSelected', false);
|
||||||
|
this.appEvents.trigger('post-stream:refresh');
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleParticipant(user) {
|
toggleParticipant(user) {
|
||||||
|
@ -293,6 +347,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
|
|
||||||
toggleMultiSelect() {
|
toggleMultiSelect() {
|
||||||
this.toggleProperty('multiSelect');
|
this.toggleProperty('multiSelect');
|
||||||
|
this.appEvents.trigger('post-stream:refresh');
|
||||||
},
|
},
|
||||||
|
|
||||||
finishedEditingTopic() {
|
finishedEditingTopic() {
|
||||||
|
@ -447,18 +502,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
expandFirstPost(post) {
|
|
||||||
const self = this;
|
|
||||||
this.set('loadingExpanded', true);
|
|
||||||
post.expand().then(function() {
|
|
||||||
self.set('firstPostExpanded', true);
|
|
||||||
}).catch(function(error) {
|
|
||||||
bootbox.alert($.parseJSON(error.responseText).errors);
|
|
||||||
}).finally(function() {
|
|
||||||
self.set('loadingExpanded', false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
retryLoading() {
|
retryLoading() {
|
||||||
const self = this;
|
const self = this;
|
||||||
self.set('retrying', true);
|
self.set('retrying', true);
|
||||||
|
@ -470,22 +513,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleWiki(post) {
|
toggleWiki(post) {
|
||||||
post.updatePostField('wiki', !post.get('wiki'));
|
return post.updatePostField('wiki', !post.get('wiki'));
|
||||||
},
|
},
|
||||||
|
|
||||||
togglePostType(post) {
|
togglePostType(post) {
|
||||||
const regular = this.site.get('post_types.regular');
|
const regular = this.site.get('post_types.regular');
|
||||||
const moderator = this.site.get('post_types.moderator_action');
|
const moderator = this.site.get('post_types.moderator_action');
|
||||||
|
|
||||||
post.updatePostField('post_type', post.get('post_type') === moderator ? regular : moderator);
|
return post.updatePostField('post_type', post.get('post_type') === moderator ? regular : moderator);
|
||||||
},
|
},
|
||||||
|
|
||||||
rebakePost(post) {
|
rebakePost(post) {
|
||||||
post.rebake();
|
return post.rebake();
|
||||||
},
|
},
|
||||||
|
|
||||||
unhidePost(post) {
|
unhidePost(post) {
|
||||||
post.unhide();
|
return post.unhide();
|
||||||
},
|
},
|
||||||
|
|
||||||
changePostOwner(post) {
|
changePostOwner(post) {
|
||||||
|
@ -498,11 +541,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
this.send('togglePinnedForUser');
|
this.send('togglePinnedForUser');
|
||||||
},
|
},
|
||||||
|
|
||||||
showExpandButton: function() {
|
|
||||||
const post = this.get('post');
|
|
||||||
return post.get('post_number') === 1 && post.get('topic.expandable_first_post');
|
|
||||||
}.property(),
|
|
||||||
|
|
||||||
canMergeTopic: function() {
|
canMergeTopic: function() {
|
||||||
if (!this.get('model.details.can_move_posts')) return false;
|
if (!this.get('model.details.can_move_posts')) return false;
|
||||||
return this.get('selectedPostsCount') > 0;
|
return this.get('selectedPostsCount') > 0;
|
||||||
|
@ -598,9 +636,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
// Unsubscribe before subscribing again
|
// Unsubscribe before subscribing again
|
||||||
this.unsubscribe();
|
this.unsubscribe();
|
||||||
|
|
||||||
const self = this;
|
const refresh = (id) => this.appEvents.trigger('post-stream:refresh', id);
|
||||||
this.messageBus.subscribe("/topic/" + this.get('model.id'), function(data) {
|
|
||||||
const topic = self.get('model');
|
this.messageBus.subscribe("/topic/" + this.get('model.id'), data => {
|
||||||
|
const topic = this.get('model');
|
||||||
|
|
||||||
if (data.notification_level_change) {
|
if (data.notification_level_change) {
|
||||||
topic.set('details.notification_level', data.notification_level_change);
|
topic.set('details.notification_level', data.notification_level_change);
|
||||||
|
@ -608,26 +647,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postStream = self.get('model.postStream');
|
const postStream = this.get('model.postStream');
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "revised":
|
case "revised":
|
||||||
case "acted":
|
case "acted":
|
||||||
case "rebaked": {
|
case "rebaked": {
|
||||||
// TODO we could update less data for "acted" (only post actions)
|
// TODO we could update less data for "acted" (only post actions)
|
||||||
postStream.triggerChangedPost(data.id, data.updated_at);
|
postStream.triggerChangedPost(data.id, data.updated_at).then(() => refresh(data.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "deleted": {
|
case "deleted": {
|
||||||
postStream.triggerDeletedPost(data.id, data.post_number);
|
postStream.triggerDeletedPost(data.id, data.post_number).then(() => refresh(data.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "recovered": {
|
case "recovered": {
|
||||||
postStream.triggerRecoveredPost(data.id, data.post_number);
|
postStream.triggerRecoveredPost(data.id, data.post_number).then(() => refresh(data.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "created": {
|
case "created": {
|
||||||
postStream.triggerNewPostInStream(data.id);
|
postStream.triggerNewPostInStream(data.id).then(() => refresh());
|
||||||
if (self.get('currentUser.id') !== data.user_id) {
|
if (this.get('currentUser.id') !== data.user_id) {
|
||||||
Discourse.notifyBackgroundCountIncrement();
|
Discourse.notifyBackgroundCountIncrement();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
@ -709,59 +748,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Called the the topmost visible post on the page changes.
|
|
||||||
topVisibleChanged(post) {
|
|
||||||
if (!post) { return; }
|
|
||||||
|
|
||||||
const postStream = this.get('model.postStream');
|
|
||||||
const firstLoadedPost = postStream.get('posts.firstObject');
|
|
||||||
|
|
||||||
this.set('model.currentPost', post.get('post_number'));
|
|
||||||
|
|
||||||
if (post.get('post_number') === 1) { return; }
|
|
||||||
|
|
||||||
if (firstLoadedPost && firstLoadedPost === post) {
|
|
||||||
// Note: jQuery shouldn't be done in a controller, but how else can we
|
|
||||||
// trigger a scroll after a promise resolves in a controller? We need
|
|
||||||
// to do this to preserve upwards infinte scrolling.
|
|
||||||
const $body = $('body');
|
|
||||||
const elemId = `#post_${post.get('post_number')}`;
|
|
||||||
const $elem = $(elemId).closest('.post-cloak');
|
|
||||||
const elemPos = $elem.position();
|
|
||||||
const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0;
|
|
||||||
|
|
||||||
postStream.prependMore().then(function() {
|
|
||||||
Em.run.next(function () {
|
|
||||||
const $refreshedElem = $(elemId).closest('.post-cloak');
|
|
||||||
|
|
||||||
// Quickly going back might mean the element is destroyed
|
|
||||||
const position = $refreshedElem.position();
|
|
||||||
if (position && position.top) {
|
|
||||||
$('html, body').scrollTop(position.top + distToElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
Called the the bottommost visible post on the page changes.
|
|
||||||
|
|
||||||
@method bottomVisibleChanged
|
|
||||||
@params {Discourse.Post} post that is at the bottom
|
|
||||||
**/
|
|
||||||
bottomVisibleChanged(post) {
|
|
||||||
if (!post) { return; }
|
|
||||||
|
|
||||||
const postStream = this.get('model.postStream');
|
|
||||||
const lastLoadedPost = postStream.get('posts.lastObject');
|
|
||||||
|
|
||||||
this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post));
|
|
||||||
|
|
||||||
if (lastLoadedPost && lastLoadedPost === post) {
|
|
||||||
postStream.appendMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_showFooter: function() {
|
_showFooter: function() {
|
||||||
const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts");
|
const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts");
|
||||||
|
|
|
@ -22,13 +22,11 @@ function loadingResolver(cb) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseName(fullName) {
|
function parseName(fullName) {
|
||||||
/*jshint validthis:true */
|
|
||||||
|
|
||||||
const nameParts = fullName.split(":"),
|
const nameParts = fullName.split(":"),
|
||||||
type = nameParts[0], fullNameWithoutType = nameParts[1],
|
type = nameParts[0], fullNameWithoutType = nameParts[1],
|
||||||
name = fullNameWithoutType,
|
name = fullNameWithoutType,
|
||||||
namespace = get(this, 'namespace'),
|
namespace = get(this, 'namespace'),
|
||||||
root = namespace;
|
root = namespace;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullName: fullName,
|
fullName: fullName,
|
||||||
|
@ -85,6 +83,10 @@ export default Ember.DefaultResolver.extend({
|
||||||
return module;
|
return module;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resolveWidget(parsedName) {
|
||||||
|
return this.customResolve(parsedName) || this._super(parsedName);
|
||||||
|
},
|
||||||
|
|
||||||
resolveAdapter(parsedName) {
|
resolveAdapter(parsedName) {
|
||||||
return this.customResolve(parsedName) || this._super(parsedName);
|
return this.customResolve(parsedName) || this._super(parsedName);
|
||||||
},
|
},
|
||||||
|
|
8
app/assets/javascripts/discourse/helpers/as-hash.js.es6
Normal file
8
app/assets/javascripts/discourse/helpers/as-hash.js.es6
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// Note: Later versions of ember include `hash`
|
||||||
|
export default function hashHelper(params) {
|
||||||
|
const hash = {};
|
||||||
|
Object.keys(params.hash).forEach(k => {
|
||||||
|
hash[k] = params.data.view.getStream(params.hash[k]).value();
|
||||||
|
});
|
||||||
|
return hash;
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
import registerUnbound from 'discourse/helpers/register-unbound';
|
import registerUnbound from 'discourse/helpers/register-unbound';
|
||||||
|
|
||||||
function iconClasses(icon, params) {
|
function iconClasses(icon, params) {
|
||||||
|
@ -7,7 +8,7 @@ function iconClasses(icon, params) {
|
||||||
return classes;
|
return classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconHTML(icon, params) {
|
export function iconHTML(icon, params) {
|
||||||
params = params || {};
|
params = params || {};
|
||||||
|
|
||||||
var html = "<i class='" + iconClasses(icon, params) + "'";
|
var html = "<i class='" + iconClasses(icon, params) + "'";
|
||||||
|
@ -19,6 +20,24 @@ function iconHTML(icon, params) {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function iconNode(icon, params) {
|
||||||
|
params = params || {};
|
||||||
|
|
||||||
|
const properties = {
|
||||||
|
className: iconClasses(icon, params),
|
||||||
|
attributes: { "aria-hidden": true }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.title) { properties.attributes.title = params.title; }
|
||||||
|
|
||||||
|
if (params.label) {
|
||||||
|
return h('i', properties, h('span.sr-only', I18n.t(params.label)));
|
||||||
|
} else {
|
||||||
|
return h('i', properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Ember.Handlebars.helper('fa-icon-bound', function(value, options) {
|
Ember.Handlebars.helper('fa-icon-bound', function(value, options) {
|
||||||
return new Handlebars.SafeString(iconHTML(value, options));
|
return new Handlebars.SafeString(iconHTML(value, options));
|
||||||
});
|
});
|
||||||
|
@ -26,5 +45,3 @@ Ember.Handlebars.helper('fa-icon-bound', function(value, options) {
|
||||||
registerUnbound('fa-icon', function(icon, params) {
|
registerUnbound('fa-icon', function(icon, params) {
|
||||||
return new Handlebars.SafeString(iconHTML(icon, params));
|
return new Handlebars.SafeString(iconHTML(icon, params));
|
||||||
});
|
});
|
||||||
|
|
||||||
export { iconHTML };
|
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
import registerUnbound from 'discourse/helpers/register-unbound';
|
|
||||||
|
|
||||||
registerUnbound('link-domain', function(link) {
|
|
||||||
if (link) {
|
|
||||||
const hasTitle = (!Ember.isEmpty(Em.get(link, 'title')));
|
|
||||||
|
|
||||||
if (hasTitle) {
|
|
||||||
let domain = Ember.get(link, 'domain');
|
|
||||||
if (!Ember.isEmpty(domain)) {
|
|
||||||
const s = domain.split('.');
|
|
||||||
domain = s[s.length-2] + "." + s[s.length-1];
|
|
||||||
return new Handlebars.SafeString("<span class='domain'>" + domain + "</span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
25
app/assets/javascripts/discourse/helpers/node.js.es6
Normal file
25
app/assets/javascripts/discourse/helpers/node.js.es6
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { relativeAge, longDate } from 'discourse/lib/formatter';
|
||||||
|
import { number } from 'discourse/lib/formatter';
|
||||||
|
|
||||||
|
export function dateNode(dt) {
|
||||||
|
if (typeof dt === "string") { dt = new Date(dt); }
|
||||||
|
if (dt) {
|
||||||
|
return h('span', { attributes: { title: longDate(dt) } }, relativeAge(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberNode(num, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
num = parseInt(num, 10);
|
||||||
|
if (isNaN(num)) { num = 0; }
|
||||||
|
|
||||||
|
const numString = num.toString();
|
||||||
|
const attributes = { };
|
||||||
|
const formatted = number(num);
|
||||||
|
if (formatted !== numString) {
|
||||||
|
attributes.title = numString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('span.number', { className: opts.className, attributes }, formatted);
|
||||||
|
}
|
|
@ -1,25 +1,23 @@
|
||||||
import ScreenTrack from 'discourse/lib/screen-track';
|
|
||||||
import Session from 'discourse/models/session';
|
import Session from 'discourse/models/session';
|
||||||
|
|
||||||
const ANON_TOPIC_IDS = 2,
|
const ANON_TOPIC_IDS = 2;
|
||||||
ANON_PROMPT_READ_TIME = 2 * 60 * 1000,
|
const ANON_PROMPT_READ_TIME = 2 * 60 * 1000;
|
||||||
ONE_DAY = 24 * 60 * 60 * 1000,
|
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||||
PROMPT_HIDE_DURATION = ONE_DAY;
|
const PROMPT_HIDE_DURATION = ONE_DAY;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "signup-cta",
|
name: "signup-cta",
|
||||||
|
|
||||||
initialize(container) {
|
initialize(container) {
|
||||||
const screenTrack = ScreenTrack.current(),
|
const screenTrack = container.lookup('screen-track:main');
|
||||||
session = Session.current(),
|
const session = Session.current();
|
||||||
siteSettings = container.lookup('site-settings:main'),
|
const siteSettings = container.lookup('site-settings:main');
|
||||||
keyValueStore = container.lookup('key-value-store:main'),
|
const keyValueStore = container.lookup('key-value-store:main');
|
||||||
user = container.lookup('current-user:main');
|
const user = container.lookup('current-user:main');
|
||||||
|
|
||||||
screenTrack.set('keyValueStore', keyValueStore);
|
screenTrack.keyValueStore = keyValueStore;
|
||||||
|
|
||||||
// Preconditions
|
// Preconditions
|
||||||
|
|
||||||
if (user) return; // must not be logged in
|
if (user) return; // must not be logged in
|
||||||
if (keyValueStore.get('anon-cta-never')) return; // "never show again"
|
if (keyValueStore.get('anon-cta-never')) return; // "never show again"
|
||||||
if (!siteSettings.allow_new_registrations) return;
|
if (!siteSettings.allow_new_registrations) return;
|
||||||
|
@ -63,7 +61,7 @@ export default {
|
||||||
session.set('showSignupCta', true);
|
session.set('showSignupCta', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
screenTrack.set('anonFlushCallback', checkSignupCtaRequirements);
|
screenTrack.registerAnonCallback(checkSignupCtaRequirements);
|
||||||
|
|
||||||
checkSignupCtaRequirements();
|
checkSignupCtaRequirements();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import CloakedCollectionView from 'discourse/views/cloaked-collection';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@module Discourse
|
@module Discourse
|
||||||
*/
|
*/
|
||||||
|
@ -221,36 +219,4 @@ const DiscourseLocation = Ember.Object.extend({
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
Since we're using pushState/replaceState let's add extra hooks to cloakedView to
|
|
||||||
eject itself when the popState occurs. This results in better back button
|
|
||||||
behavior.
|
|
||||||
**/
|
|
||||||
CloakedCollectionView.reopen({
|
|
||||||
_watchForPopState: function() {
|
|
||||||
const self = this,
|
|
||||||
cb = function() {
|
|
||||||
// Sam: This is a hack, but a very important one
|
|
||||||
// Due to the way we use replace state the back button works strangely
|
|
||||||
//
|
|
||||||
// If you visit a topic from the front page, scroll a bit around and then hit back
|
|
||||||
// you notice that first the page scrolls a bit (within the topic) and then it goes back
|
|
||||||
// this transition is jarring and adds uneeded rendering costs.
|
|
||||||
//
|
|
||||||
// To repro comment the hack out and wack a debugger statement here and in
|
|
||||||
// topic_route deactivate
|
|
||||||
$('.posts,#topic-title').hide();
|
|
||||||
self.cleanUp();
|
|
||||||
self.set('controller.model.postStream.loaded', false);
|
|
||||||
};
|
|
||||||
this.set('_callback', cb);
|
|
||||||
popstateCallbacks.addObject(cb);
|
|
||||||
}.on('didInsertElement'),
|
|
||||||
|
|
||||||
_disbandWatcher: function() {
|
|
||||||
popstateCallbacks.removeObject(this.get('_callback'));
|
|
||||||
this.set('_callback', null);
|
|
||||||
}.on('willDestroyElement')
|
|
||||||
});
|
|
||||||
|
|
||||||
export default DiscourseLocation;
|
export default DiscourseLocation;
|
||||||
|
|
|
@ -12,6 +12,5 @@ export function decorateCooked(container, cb) {
|
||||||
decorate(postView, 'postViewInserted', cb);
|
decorate(postView, 'postViewInserted', cb);
|
||||||
decorate(postView, 'postViewUpdated', cb);
|
decorate(postView, 'postViewUpdated', cb);
|
||||||
decorate(ComposerEditor, 'previewRefreshed', cb);
|
decorate(ComposerEditor, 'previewRefreshed', cb);
|
||||||
decorate(container.lookupFactory('view:embedded-post'), 'didInsertElement', cb);
|
|
||||||
decorate(container.lookupFactory('view:user-stream'), 'didInsertElement', cb);
|
decorate(container.lookupFactory('view:user-stream'), 'didInsertElement', cb);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Placeholder } from 'discourse/views/cloaked';
|
|
||||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||||
|
|
||||||
|
export function Placeholder(viewName) {
|
||||||
|
this.viewName = viewName;
|
||||||
|
}
|
||||||
|
|
||||||
export default Ember.Object.extend(Ember.Array, {
|
export default Ember.Object.extend(Ember.Array, {
|
||||||
posts: null,
|
posts: null,
|
||||||
|
|
|
@ -37,8 +37,6 @@ function positioningWorkaround($fixedElement) {
|
||||||
if (evt) {
|
if (evt) {
|
||||||
evt.target.removeEventListener('blur', blurred);
|
evt.target.removeEventListener('blur', blurred);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('body').removeData('disable-cloaked-view');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var blurred = _.debounce(blurredNow, 250);
|
var blurred = _.debounce(blurredNow, 250);
|
||||||
|
@ -63,7 +61,6 @@ function positioningWorkaround($fixedElement) {
|
||||||
|
|
||||||
// take care of body
|
// take care of body
|
||||||
|
|
||||||
$('body').data('disable-cloaked-view',true);
|
|
||||||
$('#main-outlet').hide();
|
$('#main-outlet').hide();
|
||||||
$('header').hide();
|
$('header').hide();
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
// We use this class to track how long posts in a topic are on the screen.
|
// We use this class to track how long posts in a topic are on the screen.
|
||||||
|
const PAUSE_UNLESS_SCROLLED = 1000 * 60 * 3;
|
||||||
|
const MAX_TRACKING_TIME = 1000 * 60 * 6;
|
||||||
|
const ANON_MAX_TOPIC_IDS = 5;
|
||||||
|
|
||||||
import Singleton from 'discourse/mixins/singleton';
|
const getTime = () => new Date().getTime();
|
||||||
|
|
||||||
const PAUSE_UNLESS_SCROLLED = 1000 * 60 * 3,
|
export default class {
|
||||||
MAX_TRACKING_TIME = 1000 * 60 * 6,
|
constructor(topicTrackingState, siteSettings, session, currentUser) {
|
||||||
ANON_MAX_TOPIC_IDS = 5;
|
this.topicTrackingState = topicTrackingState;
|
||||||
|
this.siteSettings = siteSettings;
|
||||||
const ScreenTrack = Ember.Object.extend({
|
this.session = session;
|
||||||
|
this.currentUser = currentUser;
|
||||||
init() {
|
|
||||||
this.reset();
|
this.reset();
|
||||||
|
}
|
||||||
// TODO: Move `ScreenTrack` to injection and remove this
|
|
||||||
this.set('topicTrackingState', Discourse.__container__.lookup('topic-tracking-state:main'));
|
|
||||||
},
|
|
||||||
|
|
||||||
start(topicId, topicController) {
|
start(topicId, topicController) {
|
||||||
const currentTopicId = this.get('topicId');
|
const currentTopicId = this._topicId;
|
||||||
if (currentTopicId && (currentTopicId !== topicId)) {
|
if (currentTopicId && (currentTopicId !== topicId)) {
|
||||||
this.tick();
|
this.tick();
|
||||||
this.flush();
|
this.flush();
|
||||||
|
@ -25,90 +24,81 @@ const ScreenTrack = Ember.Object.extend({
|
||||||
this.reset();
|
this.reset();
|
||||||
|
|
||||||
// Create an interval timer if we don't have one.
|
// Create an interval timer if we don't have one.
|
||||||
if (!this.get('interval')) {
|
if (!this._interval) {
|
||||||
const self = this;
|
this._interval = setInterval(() => this.tick(), 1000);
|
||||||
this.set('interval', setInterval(function () {
|
$(window).on('scroll.screentrack', () => this.scrolled());
|
||||||
self.tick();
|
|
||||||
}, 1000));
|
|
||||||
|
|
||||||
$(window).on('scroll.screentrack', function(){self.scrolled();});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set('topicId', topicId);
|
this._topicId = topicId;
|
||||||
this.set('topicController', topicController);
|
this._topicController = topicController;
|
||||||
},
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if(!this.get('topicId')) {
|
// already stopped no need to "extra stop"
|
||||||
// already stopped no need to "extra stop"
|
if(!this._topicId) { return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
$(window).off('scroll.screentrack');
|
$(window).off('scroll.screentrack');
|
||||||
this.tick();
|
this.tick();
|
||||||
this.flush();
|
this.flush();
|
||||||
this.reset();
|
this.reset();
|
||||||
this.set('topicId', null);
|
|
||||||
this.set('topicController', null);
|
this._topicId = null;
|
||||||
if (this.get('interval')) {
|
this._topicController = null;
|
||||||
clearInterval(this.get('interval'));
|
|
||||||
this.set('interval', null);
|
if (this._interval) {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
this._interval = null;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
track(elementId, postNumber) {
|
setOnscreen(onscreen) {
|
||||||
this.get('timings')["#" + elementId] = {
|
this._onscreen = onscreen;
|
||||||
time: 0,
|
}
|
||||||
postNumber: postNumber
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
stopTracking(elementId) {
|
|
||||||
delete this.get('timings')['#' + elementId];
|
|
||||||
},
|
|
||||||
|
|
||||||
// Reset our timers
|
// Reset our timers
|
||||||
reset() {
|
reset() {
|
||||||
this.setProperties({
|
const now = getTime();
|
||||||
lastTick: new Date().getTime(),
|
this._lastTick = now;
|
||||||
lastScrolled: new Date().getTime(),
|
this._lastScrolled = now;
|
||||||
lastFlush: 0,
|
this._lastFlush = 0;
|
||||||
cancelled: false,
|
this._timings = {};
|
||||||
timings: {},
|
this._totalTimings = {};
|
||||||
totalTimings: {},
|
this._topicTime = 0;
|
||||||
topicTime: 0
|
this._onscreen = [];
|
||||||
});
|
}
|
||||||
},
|
|
||||||
|
|
||||||
scrolled() {
|
scrolled() {
|
||||||
this.set('lastScrolled', new Date().getTime());
|
this._lastScrolled = getTime();
|
||||||
},
|
}
|
||||||
|
|
||||||
|
registerAnonCallback(cb) {
|
||||||
|
this._anonCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
if (this.get('cancelled')) { return; }
|
const newTimings = {};
|
||||||
|
const totalTimings = this._totalTimings;
|
||||||
|
|
||||||
const newTimings = {},
|
const timings = this._timings;
|
||||||
totalTimings = this.get('totalTimings'),
|
Object.keys(this._timings).forEach(postNumber => {
|
||||||
self = this;
|
const time = timings[postNumber];
|
||||||
|
totalTimings[postNumber] = totalTimings[postNumber] || 0;
|
||||||
|
|
||||||
_.each(this.get('timings'), function(timing) {
|
if (time > 0 && totalTimings[postNumber] < MAX_TRACKING_TIME) {
|
||||||
if (!totalTimings[timing.postNumber])
|
totalTimings[postNumber] += time;
|
||||||
totalTimings[timing.postNumber] = 0;
|
newTimings[postNumber] = time;
|
||||||
|
|
||||||
if (timing.time > 0 && totalTimings[timing.postNumber] < MAX_TRACKING_TIME) {
|
|
||||||
totalTimings[timing.postNumber] += timing.time;
|
|
||||||
newTimings[timing.postNumber] = timing.time;
|
|
||||||
}
|
}
|
||||||
timing.time = 0;
|
timings[postNumber] = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const topicId = parseInt(this.get('topicId'), 10);
|
const topicId = parseInt(this._topicId, 10);
|
||||||
let highestSeen = 0;
|
let highestSeen = 0;
|
||||||
|
|
||||||
_.each(newTimings, function(time,postNumber) {
|
Object.keys(newTimings).forEach(postNumber => {
|
||||||
highestSeen = Math.max(highestSeen, parseInt(postNumber, 10));
|
highestSeen = Math.max(highestSeen, parseInt(postNumber, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
const highestSeenByTopic = Discourse.Session.currentProp('highestSeenByTopic');
|
const highestSeenByTopic = this.session.get('highestSeenByTopic');
|
||||||
if ((highestSeenByTopic[topicId] || 0) < highestSeen) {
|
if ((highestSeenByTopic[topicId] || 0) < highestSeen) {
|
||||||
highestSeenByTopic[topicId] = highestSeen;
|
highestSeenByTopic[topicId] = highestSeen;
|
||||||
}
|
}
|
||||||
|
@ -116,11 +106,11 @@ const ScreenTrack = Ember.Object.extend({
|
||||||
this.topicTrackingState.updateSeen(topicId, highestSeen);
|
this.topicTrackingState.updateSeen(topicId, highestSeen);
|
||||||
|
|
||||||
if (!$.isEmptyObject(newTimings)) {
|
if (!$.isEmptyObject(newTimings)) {
|
||||||
if (Discourse.User.current()) {
|
if (this.currentUser) {
|
||||||
Discourse.ajax('/topics/timings', {
|
Discourse.ajax('/topics/timings', {
|
||||||
data: {
|
data: {
|
||||||
timings: newTimings,
|
timings: newTimings,
|
||||||
topic_time: this.get('topicTime'),
|
topic_time: this._topicTime,
|
||||||
topic_id: topicId
|
topic_id: topicId
|
||||||
},
|
},
|
||||||
cache: false,
|
cache: false,
|
||||||
|
@ -128,22 +118,20 @@ const ScreenTrack = Ember.Object.extend({
|
||||||
headers: {
|
headers: {
|
||||||
'X-SILENCE-LOGGER': 'true'
|
'X-SILENCE-LOGGER': 'true'
|
||||||
}
|
}
|
||||||
}).then(function() {
|
}).then(() => {
|
||||||
const controller = self.get('topicController');
|
const controller = this._topicController;
|
||||||
if (controller) {
|
if (controller) {
|
||||||
const postNumbers = Object.keys(newTimings).map(function(v) {
|
const postNumbers = Object.keys(newTimings).map(v => parseInt(v, 10));
|
||||||
return parseInt(v, 10);
|
|
||||||
});
|
|
||||||
controller.readPosts(topicId, postNumbers);
|
controller.readPosts(topicId, postNumbers);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (this.get('anonFlushCallback')) {
|
} else if (this._anonCallback) {
|
||||||
// Anonymous viewer - save to localStorage
|
// Anonymous viewer - save to localStorage
|
||||||
const storage = this.get('keyValueStore');
|
const storage = this.keyValueStore;
|
||||||
|
|
||||||
// Save total time
|
// Save total time
|
||||||
const existingTime = storage.getInt('anon-topic-time');
|
const existingTime = storage.getInt('anon-topic-time');
|
||||||
storage.setItem('anon-topic-time', existingTime + this.get('topicTime'));
|
storage.setItem('anon-topic-time', existingTime + this._topicTime);
|
||||||
|
|
||||||
// Save unique topic IDs up to a max
|
// Save unique topic IDs up to a max
|
||||||
let topicIds = storage.get('anon-topic-ids');
|
let topicIds = storage.get('anon-topic-ids');
|
||||||
|
@ -158,64 +146,47 @@ const ScreenTrack = Ember.Object.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inform the observer
|
// Inform the observer
|
||||||
this.get('anonFlushCallback')();
|
this._anonCallback();
|
||||||
|
|
||||||
// No need to call controller.readPosts()
|
// No need to call controller.readPosts()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set('topicTime', 0);
|
this._topicTime = 0;
|
||||||
}
|
}
|
||||||
this.set('lastFlush', 0);
|
|
||||||
},
|
this._lastFlush = 0;
|
||||||
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
|
||||||
// If the user hasn't scrolled the browser in a long time, stop tracking time read
|
// If the user hasn't scrolled the browser in a long time, stop tracking time read
|
||||||
const sinceScrolled = new Date().getTime() - this.get('lastScrolled');
|
const sinceScrolled = now - this._lastScrolled;
|
||||||
if (sinceScrolled > PAUSE_UNLESS_SCROLLED) {
|
if (sinceScrolled > PAUSE_UNLESS_SCROLLED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = new Date().getTime() - this.get('lastTick');
|
const diff = now - this._lastTick;
|
||||||
this.set('lastFlush', this.get('lastFlush') + diff);
|
this._lastFlush += diff;
|
||||||
this.set('lastTick', new Date().getTime());
|
this._lastTick = now;
|
||||||
|
|
||||||
const totalTimings = this.get('totalTimings'), timings = this.get('timings');
|
const totalTimings = this._totalTimings;
|
||||||
const nextFlush = Discourse.SiteSettings.flush_timings_secs * 1000;
|
const timings = this._timings;
|
||||||
|
const nextFlush = this.siteSettings.flush_timings_secs * 1000;
|
||||||
|
|
||||||
// rush new post numbers
|
const rush = Object.keys(timings).some(postNumber => {
|
||||||
const rush = _.any(_.filter(timings, function(t){return t.time>0;}), function(t){
|
return timings[postNumber] > 0 && !totalTimings[postNumber];
|
||||||
return !totalTimings[t.postNumber];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.get('lastFlush') > nextFlush || rush) {
|
if (this._lastFlush > nextFlush || rush) {
|
||||||
this.flush();
|
this.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't track timings if we're not in focus
|
// Don't track timings if we're not in focus
|
||||||
if (!Discourse.get("hasFocus")) return;
|
if (!Discourse.get("hasFocus")) return;
|
||||||
|
|
||||||
this.set('topicTime', this.get('topicTime') + diff);
|
this._topicTime += diff;
|
||||||
const docViewTop = $(window).scrollTop() + $('header').height(),
|
|
||||||
docViewBottom = docViewTop + $(window).height();
|
|
||||||
|
|
||||||
// TODO: Eyeline has a smarter more accurate function here. It's bad to do jQuery
|
this._onscreen.forEach(postNumber => timings[postNumber] = (timings[postNumber] || 0) + diff);
|
||||||
// in a model like component, so we should refactor this out later.
|
|
||||||
_.each(this.get('timings'),function(timing,id) {
|
|
||||||
const $element = $(id);
|
|
||||||
if ($element.length === 1) {
|
|
||||||
const elemTop = $element.offset().top,
|
|
||||||
elemBottom = elemTop + $element.height();
|
|
||||||
|
|
||||||
// If part of the element is on the screen, increase the counter
|
|
||||||
if (((docViewTop <= elemTop && elemTop <= docViewBottom)) || ((docViewTop <= elemBottom && elemBottom <= docViewBottom))) {
|
|
||||||
timing.time = timing.time + diff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
|
||||||
ScreenTrack.reopenClass(Singleton);
|
|
||||||
export default ScreenTrack;
|
|
||||||
|
|
191
app/assets/javascripts/discourse/lib/transform-post.js.es6
Normal file
191
app/assets/javascripts/discourse/lib/transform-post.js.es6
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
function actionDescription(action, acted, count) {
|
||||||
|
if (acted) {
|
||||||
|
if (count <= 1) {
|
||||||
|
return I18n.t(`post.actions.by_you.${action}`);
|
||||||
|
} else {
|
||||||
|
return I18n.t(`post.actions.by_you_and_others.${action}`, { count: count - 1 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return I18n.t(`post.actions.by_others.${action}`, { count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformBasicPost(post) {
|
||||||
|
// Note: it can be dangerous to not use `get` in Ember code, but this is significantly
|
||||||
|
// faster and has tests to confirm it works. We only call `get` when the property is a CP
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
hidden: post.hidden,
|
||||||
|
deleted: post.get('deleted'),
|
||||||
|
deleted_at: post.deleted_at,
|
||||||
|
user_deleted: post.user_deleted,
|
||||||
|
isDeleted: post.deleted_at || post.user_deleted,
|
||||||
|
deletedByAvatarTemplate: null,
|
||||||
|
deletedByUsername: null,
|
||||||
|
primary_group_name: post.primary_group_name,
|
||||||
|
wiki: post.wiki,
|
||||||
|
firstPost: post.post_number === 1,
|
||||||
|
post_number: post.post_number,
|
||||||
|
cooked: post.cooked,
|
||||||
|
via_email: post.via_email,
|
||||||
|
user_id: post.user_id,
|
||||||
|
usernameUrl: Discourse.getURL(`/users/${post.username}`),
|
||||||
|
username: post.username,
|
||||||
|
avatar_template: post.avatar_template,
|
||||||
|
bookmarked: post.bookmarked,
|
||||||
|
yours: post.yours,
|
||||||
|
shareUrl: post.get('shareUrl'),
|
||||||
|
staff: post.staff,
|
||||||
|
admin: post.admin,
|
||||||
|
moderator: post.moderator,
|
||||||
|
new_user: post.trust_level === 0,
|
||||||
|
name: post.name,
|
||||||
|
user_title: post.user_title,
|
||||||
|
created_at: post.created_at,
|
||||||
|
updated_at: post.updated_at,
|
||||||
|
canDelete: post.can_delete,
|
||||||
|
canRecover: post.can_recover,
|
||||||
|
canEdit: post.can_edit,
|
||||||
|
canFlag: !Ember.isEmpty(post.flagsAvailable),
|
||||||
|
version: post.version,
|
||||||
|
canRecoverTopic: false,
|
||||||
|
canDeletedTopic: false,
|
||||||
|
canViewEditHistory: post.can_view_edit_history,
|
||||||
|
canWiki: post.can_wiki,
|
||||||
|
showLike: false,
|
||||||
|
liked: false,
|
||||||
|
canToggleLike: false,
|
||||||
|
likeCount: false,
|
||||||
|
actionsSummary: null,
|
||||||
|
read: post.read,
|
||||||
|
replyToUsername: null,
|
||||||
|
replyToAvatarTemplate: null,
|
||||||
|
reply_to_post_number: post.reply_to_post_number,
|
||||||
|
cooked_hidden: !!post.cooked_hidden,
|
||||||
|
expandablePost: false,
|
||||||
|
replyCount: post.reply_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function transformPost(currentUser, site, post, prevPost, nextPost) {
|
||||||
|
// Note: it can be dangerous to not use `get` in Ember code, but this is significantly
|
||||||
|
// faster and has tests to confirm it works. We only call `get` when the property is a CP
|
||||||
|
const postType = post.post_type;
|
||||||
|
const postTypes = site.post_types;
|
||||||
|
const topic = post.topic;
|
||||||
|
const details = topic.get('details');
|
||||||
|
|
||||||
|
const postAtts = transformBasicPost(post);
|
||||||
|
|
||||||
|
postAtts.topicId = topic.id;
|
||||||
|
postAtts.topicOwner = details.created_by.id === post.user_id;
|
||||||
|
postAtts.post_type = postType;
|
||||||
|
postAtts.via_email = post.via_email;
|
||||||
|
postAtts.isModeratorAction = postType === postTypes.moderator_action;
|
||||||
|
postAtts.isWhisper = postType === postTypes.whisper;
|
||||||
|
postAtts.isSmallAction = postType === postTypes.small_action;
|
||||||
|
postAtts.canBookmark = !!currentUser;
|
||||||
|
postAtts.canManage = currentUser && currentUser.get('canManageTopic');
|
||||||
|
postAtts.canViewRawEmail = currentUser && (currentUser.id === post.user_id || currentUser.staff);
|
||||||
|
postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
|
||||||
|
postAtts.isWarning = topic.is_warning;
|
||||||
|
postAtts.links = post.get('internalLinks');
|
||||||
|
postAtts.replyDirectlyBelow = nextPost && nextPost.reply_to_post_number === post.post_number;
|
||||||
|
postAtts.replyDirectlyAbove = prevPost && post.reply_to_post_number === prevPost.post_number;
|
||||||
|
postAtts.linkCounts = post.link_counts;
|
||||||
|
postAtts.actionCode = post.action_code;
|
||||||
|
postAtts.actionCodeWho = post.action_code_who;
|
||||||
|
|
||||||
|
const showPMMap = topic.archetype === 'private_message' && post.post_number === 1;
|
||||||
|
if (showPMMap) {
|
||||||
|
postAtts.showPMMap = true;
|
||||||
|
postAtts.allowedGroups = details.allowed_groups;
|
||||||
|
postAtts.allowedUsers = details.allowed_users;
|
||||||
|
postAtts.canRemoveAllowedUsers = details.can_remove_allowed_users;
|
||||||
|
postAtts.canInvite = details.can_invite_to;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTopicMap = showPMMap || (post.post_number === 1 && topic.archetype === 'regular' && topic.posts_count > 1);
|
||||||
|
if (showTopicMap) {
|
||||||
|
postAtts.showTopicMap = true;
|
||||||
|
postAtts.topicUrl = topic.get('url');
|
||||||
|
postAtts.topicCreatedAt = topic.created_at;
|
||||||
|
postAtts.createdByUsername = details.created_by.username;
|
||||||
|
postAtts.createdByAvatarTemplate = details.created_by.avatar_template;
|
||||||
|
|
||||||
|
postAtts.lastPostUrl = topic.get('lastPostUrl');
|
||||||
|
postAtts.lastPostUsername = details.last_poster.username;
|
||||||
|
postAtts.lastPostAvatarTemplate = details.last_poster.avatar_template;
|
||||||
|
postAtts.lastPostAt = topic.last_posted_at;
|
||||||
|
|
||||||
|
postAtts.topicReplyCount = topic.get('replyCount');
|
||||||
|
postAtts.topicViews = topic.views;
|
||||||
|
postAtts.topicViewsHeat = topic.get('viewsHeat');
|
||||||
|
|
||||||
|
postAtts.participantCount = topic.participant_count;
|
||||||
|
postAtts.topicLikeCount = topic.like_count;
|
||||||
|
postAtts.topicLinks = details.links;
|
||||||
|
if (postAtts.topicLinks) {
|
||||||
|
postAtts.topicLinkLength = details.links.length;
|
||||||
|
}
|
||||||
|
postAtts.topicPostsCount = topic.posts_count;
|
||||||
|
|
||||||
|
postAtts.participants = details.participants;
|
||||||
|
|
||||||
|
const postStream = topic.get('postStream');
|
||||||
|
postAtts.userFilters = postStream.userFilters;
|
||||||
|
postAtts.topicSummaryEnabled = postStream.summary;
|
||||||
|
postAtts.topicWordCount = topic.word_count;
|
||||||
|
postAtts.hasTopicSummary = topic.has_summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postAtts.isDeleted) {
|
||||||
|
postAtts.deletedByAvatarTemplate = post.get('postDeletedBy.avatar_template');
|
||||||
|
postAtts.deletedByUsername = post.get('postDeletedBy.username');
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyToUser = post.get('reply_to_user');
|
||||||
|
if (replyToUser) {
|
||||||
|
postAtts.replyToUsername = replyToUser.username;
|
||||||
|
postAtts.replyToAvatarTemplate = replyToUser.avatar_template;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.actions_summary) {
|
||||||
|
postAtts.actionsSummary = post.actions_summary.filter(a => {
|
||||||
|
return a.actionType.name_key !== 'like' && a.count > 0;
|
||||||
|
}).map(a => {
|
||||||
|
const acted = a.acted;
|
||||||
|
const action = a.actionType.name_key;
|
||||||
|
const count = a.count;
|
||||||
|
|
||||||
|
return { id: a.id,
|
||||||
|
postId: post.id,
|
||||||
|
action,
|
||||||
|
acted,
|
||||||
|
count,
|
||||||
|
canUndo: a.can_undo,
|
||||||
|
canDeferFlags: a.can_defer_flags,
|
||||||
|
description: actionDescription(action, acted, count) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeAction = post.likeAction;
|
||||||
|
if (likeAction) {
|
||||||
|
postAtts.showLike = true;
|
||||||
|
postAtts.liked = likeAction.acted;
|
||||||
|
postAtts.canToggleLike = likeAction.get('canToggle');
|
||||||
|
postAtts.likeCount = likeAction.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postAtts.post_number === 1) {
|
||||||
|
postAtts.canRecoverTopic = topic.deleted_at && details.can_recover;
|
||||||
|
postAtts.canDeleteTopic = !topic.deleted_at && details.can_delete;
|
||||||
|
postAtts.expandablePost = topic.expandable_first_post;
|
||||||
|
} else {
|
||||||
|
postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover;
|
||||||
|
postAtts.canDelete = !postAtts.isDeleted && postAtts.canDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
return postAtts;
|
||||||
|
}
|
|
@ -14,10 +14,9 @@ const DiscourseURL = Ember.Object.createWithMixins({
|
||||||
/**
|
/**
|
||||||
Jumps to a particular post in the stream
|
Jumps to a particular post in the stream
|
||||||
**/
|
**/
|
||||||
jumpToPost: function(postNumber, opts) {
|
jumpToPost(postNumber, opts) {
|
||||||
const holderId = `.post-cloak[data-post-number=${postNumber}]`;
|
const holderId = `#post_${postNumber}`;
|
||||||
const offset = function() {
|
const offset = () => {
|
||||||
|
|
||||||
const $header = $('header');
|
const $header = $('header');
|
||||||
const $title = $('#topic-title');
|
const $title = $('#topic-title');
|
||||||
const windowHeight = $(window).height() - $title.height();
|
const windowHeight = $(window).height() - $title.height();
|
||||||
|
@ -26,8 +25,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
|
||||||
return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset);
|
return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Em.run.schedule('afterRender', () => {
|
||||||
Em.run.schedule('afterRender', function() {
|
|
||||||
if (postNumber === 1) {
|
if (postNumber === 1) {
|
||||||
$(window).scrollTop(0);
|
$(window).scrollTop(0);
|
||||||
return;
|
return;
|
||||||
|
@ -37,21 +35,18 @@ const DiscourseURL = Ember.Object.createWithMixins({
|
||||||
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
|
// if we are on screen skip
|
||||||
const elementTop = lockon.elementTop(),
|
const elementTop = lockon.elementTop(),
|
||||||
scrollTop = $(window).scrollTop(),
|
scrollTop = $(window).scrollTop(),
|
||||||
windowHeight = $(window).height()-offset(),
|
windowHeight = $(window).height()-offset(),
|
||||||
height = holder.height();
|
height = holder.height();
|
||||||
|
|
||||||
if (elementTop > scrollTop &&
|
if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) {
|
||||||
(elementTop + height) < (scrollTop + windowHeight)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lockon.lock();
|
lockon.lock();
|
||||||
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -3,20 +3,6 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||||
|
|
||||||
export default RestModel.extend({
|
export default RestModel.extend({
|
||||||
|
|
||||||
// Description for the action
|
|
||||||
description: function() {
|
|
||||||
const action = this.get('actionType.name_key');
|
|
||||||
if (this.get('acted')) {
|
|
||||||
if (this.get('count') <= 1) {
|
|
||||||
return I18n.t('post.actions.by_you.' + action);
|
|
||||||
} else {
|
|
||||||
return I18n.t('post.actions.by_you_and_others.' + action, { count: this.get('count') - 1 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return I18n.t('post.actions.by_others.' + action, { count: this.get('count') });
|
|
||||||
}
|
|
||||||
}.property('count', 'acted', 'actionType'),
|
|
||||||
|
|
||||||
canToggle: function() {
|
canToggle: function() {
|
||||||
return this.get('can_undo') || this.get('can_act');
|
return this.get('can_undo') || this.get('can_act');
|
||||||
}.property('can_undo', 'can_act'),
|
}.property('can_undo', 'can_act'),
|
||||||
|
@ -31,7 +17,14 @@ export default RestModel.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
toggle: function(post) {
|
togglePromise(post) {
|
||||||
|
if (!this.get('acted')) {
|
||||||
|
return this.act(post).then(() => true);
|
||||||
|
}
|
||||||
|
return this.undo(post).then(() => false);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(post) {
|
||||||
if (!this.get('acted')) {
|
if (!this.get('acted')) {
|
||||||
this.act(post);
|
this.act(post);
|
||||||
return true;
|
return true;
|
||||||
|
@ -42,7 +35,7 @@ export default RestModel.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Perform this action
|
// Perform this action
|
||||||
act: function(post, opts) {
|
act(post, opts) {
|
||||||
|
|
||||||
if (!opts) opts = {};
|
if (!opts) opts = {};
|
||||||
|
|
||||||
|
@ -83,37 +76,20 @@ export default RestModel.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Undo this action
|
// Undo this action
|
||||||
undo: function(post) {
|
undo(post) {
|
||||||
this.removeAction(post);
|
this.removeAction(post);
|
||||||
|
|
||||||
// Remove our post action
|
// Remove our post action
|
||||||
return Discourse.ajax("/post_actions/" + post.get('id'), {
|
return Discourse.ajax("/post_actions/" + post.get('id'), {
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
data: {
|
data: { post_action_type_id: this.get('id') }
|
||||||
post_action_type_id: this.get('id')
|
}).then(result => post.updateActionsSummary(result));
|
||||||
}
|
|
||||||
}).then(function(result) {
|
|
||||||
return post.updateActionsSummary(result);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deferFlags: function(post) {
|
deferFlags(post) {
|
||||||
const self = this;
|
|
||||||
return Discourse.ajax("/post_actions/defer_flags", {
|
return Discourse.ajax("/post_actions/defer_flags", {
|
||||||
type: "POST",
|
type: "POST",
|
||||||
data: {
|
data: { post_action_type_id: this.get("id"), id: post.get('id') }
|
||||||
post_action_type_id: this.get("id"),
|
}).then(() => this.set('count', 0));
|
||||||
id: post.get('id')
|
|
||||||
}
|
|
||||||
}).then(function () {
|
|
||||||
self.set("count", 0);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadUsers(post) {
|
|
||||||
return this.store.find('post-action-user', {
|
|
||||||
id: post.get('id'),
|
|
||||||
post_action_type_id: this.get('id')
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,21 +4,6 @@ import PostsWithPlaceholders from 'discourse/lib/posts-with-placeholders';
|
||||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||||
import { loadTopicView } from 'discourse/models/topic';
|
import { loadTopicView } from 'discourse/models/topic';
|
||||||
|
|
||||||
function calcDayDiff(p1, p2) {
|
|
||||||
if (!p1) { return; }
|
|
||||||
|
|
||||||
const date = p1.get('created_at');
|
|
||||||
if (date && p2) {
|
|
||||||
const lastDate = p2.get('created_at');
|
|
||||||
if (lastDate) {
|
|
||||||
const delta = new Date(date).getTime() - new Date(lastDate).getTime();
|
|
||||||
const days = Math.round(delta / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
p1.set('daysSincePrevious', days);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RestModel.extend({
|
export default RestModel.extend({
|
||||||
_identityMap: null,
|
_identityMap: null,
|
||||||
posts: null,
|
posts: null,
|
||||||
|
@ -414,7 +399,6 @@ export default RestModel.extend({
|
||||||
const stored = this.storePost(post);
|
const stored = this.storePost(post);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const posts = this.get('posts');
|
const posts = this.get('posts');
|
||||||
calcDayDiff(posts.get('firstObject'), stored);
|
|
||||||
posts.unshiftObject(stored);
|
posts.unshiftObject(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -426,7 +410,6 @@ export default RestModel.extend({
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const posts = this.get('posts');
|
const posts = this.get('posts');
|
||||||
|
|
||||||
calcDayDiff(stored, this.get('lastAppended'));
|
|
||||||
if (!posts.contains(stored)) {
|
if (!posts.contains(stored)) {
|
||||||
if (!this.get('loadingBelow')) {
|
if (!this.get('loadingBelow')) {
|
||||||
this.get('postsWithPlaceholders').appendPost(() => posts.pushObject(stored));
|
this.get('postsWithPlaceholders').appendPost(() => posts.pushObject(stored));
|
||||||
|
@ -471,10 +454,12 @@ export default RestModel.extend({
|
||||||
have no filters.
|
have no filters.
|
||||||
**/
|
**/
|
||||||
triggerNewPostInStream(postId) {
|
triggerNewPostInStream(postId) {
|
||||||
if (!postId) { return; }
|
const resolved = Ember.RSVP.Promise.resolve();
|
||||||
|
|
||||||
|
if (!postId) { return resolved; }
|
||||||
|
|
||||||
// We only trigger if there are no filters active
|
// We only trigger if there are no filters active
|
||||||
if (!this.get('hasNoFilters')) { return; }
|
if (!this.get('hasNoFilters')) { return resolved; }
|
||||||
|
|
||||||
const loadedAllPosts = this.get('loadedAllPosts');
|
const loadedAllPosts = this.get('loadedAllPosts');
|
||||||
|
|
||||||
|
@ -482,25 +467,27 @@ export default RestModel.extend({
|
||||||
this.get('stream').addObject(postId);
|
this.get('stream').addObject(postId);
|
||||||
if (loadedAllPosts) {
|
if (loadedAllPosts) {
|
||||||
this.set('loadingLastPost', true);
|
this.set('loadingLastPost', true);
|
||||||
this.findPostsByIds([postId]).then(posts => {
|
return this.findPostsByIds([postId]).then(posts => {
|
||||||
posts.forEach(p => this.appendPost(p));
|
posts.forEach(p => this.appendPost(p));
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.set('loadingLastPost', false);
|
this.set('loadingLastPost', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
},
|
},
|
||||||
|
|
||||||
triggerRecoveredPost(postId) {
|
triggerRecoveredPost(postId) {
|
||||||
const existing = this._identityMap[postId];
|
const existing = this._identityMap[postId];
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.triggerChangedPost(postId, new Date());
|
return this.triggerChangedPost(postId, new Date());
|
||||||
} else {
|
} else {
|
||||||
// need to insert into stream
|
// need to insert into stream
|
||||||
const url = "/posts/" + postId;
|
const url = "/posts/" + postId;
|
||||||
const store = this.store;
|
const store = this.store;
|
||||||
Discourse.ajax(url).then(p => {
|
return Discourse.ajax(url).then(p => {
|
||||||
const post = store.createRecord('post', p);
|
const post = store.createRecord('post', p);
|
||||||
const stream = this.get("stream");
|
const stream = this.get("stream");
|
||||||
const posts = this.get("posts");
|
const posts = this.get("posts");
|
||||||
|
@ -541,34 +528,26 @@ export default RestModel.extend({
|
||||||
const url = "/posts/" + postId;
|
const url = "/posts/" + postId;
|
||||||
const store = this.store;
|
const store = this.store;
|
||||||
|
|
||||||
Discourse.ajax(url).then(p => {
|
return Discourse.ajax(url).then(p => {
|
||||||
this.storePost(store.createRecord('post', p));
|
this.storePost(store.createRecord('post', p));
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.removePosts([existing]);
|
this.removePosts([existing]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Ember.RSVP.Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
triggerChangedPost(postId, updatedAt) {
|
triggerChangedPost(postId, updatedAt) {
|
||||||
if (!postId) { return; }
|
const resolved = Ember.RSVP.Promise.resolve();
|
||||||
|
if (!postId) { return resolved; }
|
||||||
|
|
||||||
const existing = this._identityMap[postId];
|
const existing = this._identityMap[postId];
|
||||||
if (existing && existing.updated_at !== updatedAt) {
|
if (existing && existing.updated_at !== updatedAt) {
|
||||||
const url = "/posts/" + postId;
|
const url = "/posts/" + postId;
|
||||||
const store = this.store;
|
const store = this.store;
|
||||||
Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p)));
|
return Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p)));
|
||||||
}
|
}
|
||||||
},
|
return resolved;
|
||||||
|
|
||||||
// Returns the "thread" of posts in the history of a post.
|
|
||||||
findReplyHistory(post) {
|
|
||||||
const url = `/posts/${post.get('id')}/reply-history.json?max_replies=${Discourse.SiteSettings.max_reply_history}`;
|
|
||||||
const store = this.store;
|
|
||||||
return Discourse.ajax(url).then(result => {
|
|
||||||
return result.map(p => this.storePost(store.createRecord('post', p)));
|
|
||||||
}).then(replyHistory => {
|
|
||||||
post.set('replyHistory', replyHistory);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -7,10 +7,6 @@ import computed from 'ember-addons/ember-computed-decorators';
|
||||||
|
|
||||||
const Post = RestModel.extend({
|
const Post = RestModel.extend({
|
||||||
|
|
||||||
init() {
|
|
||||||
this.set('replyHistory', []);
|
|
||||||
},
|
|
||||||
|
|
||||||
@computed()
|
@computed()
|
||||||
siteSettings() {
|
siteSettings() {
|
||||||
// TODO: Remove this once one instantiate all `Discourse.Post` models via the store.
|
// TODO: Remove this once one instantiate all `Discourse.Post` models via the store.
|
||||||
|
@ -35,11 +31,6 @@ const Post = RestModel.extend({
|
||||||
deletedViaTopic: Em.computed.and('firstPost', 'topic.deleted_at'),
|
deletedViaTopic: Em.computed.and('firstPost', 'topic.deleted_at'),
|
||||||
deleted: Em.computed.or('deleted_at', 'deletedViaTopic'),
|
deleted: Em.computed.or('deleted_at', 'deletedViaTopic'),
|
||||||
notDeleted: Em.computed.not('deleted'),
|
notDeleted: Em.computed.not('deleted'),
|
||||||
userDeleted: Em.computed.empty('user_id'),
|
|
||||||
|
|
||||||
hasTimeGap: function() {
|
|
||||||
return (this.get('daysSincePrevious') || 0) > Discourse.SiteSettings.show_time_gap_days;
|
|
||||||
}.property('daysSincePrevious'),
|
|
||||||
|
|
||||||
showName: function() {
|
showName: function() {
|
||||||
const name = this.get('name');
|
const name = this.get('name');
|
||||||
|
@ -68,25 +59,13 @@ const Post = RestModel.extend({
|
||||||
|
|
||||||
usernameUrl: url('username', '/users/%@'),
|
usernameUrl: url('username', '/users/%@'),
|
||||||
|
|
||||||
showUserReplyTab: function() {
|
|
||||||
return this.get('reply_to_user') && (
|
|
||||||
!Discourse.SiteSettings.suppress_reply_directly_above ||
|
|
||||||
this.get('reply_to_post_number') < (this.get('post_number') - 1)
|
|
||||||
);
|
|
||||||
}.property('reply_to_user', 'reply_to_post_number', 'post_number'),
|
|
||||||
|
|
||||||
topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'),
|
topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'),
|
||||||
hasHistory: Em.computed.gt('version', 1),
|
|
||||||
|
|
||||||
canViewRawEmail: function() {
|
|
||||||
return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff');
|
|
||||||
}.property("user_id"),
|
|
||||||
|
|
||||||
updatePostField(field, value) {
|
updatePostField(field, value) {
|
||||||
const data = {};
|
const data = {};
|
||||||
data[field] = value;
|
data[field] = value;
|
||||||
|
|
||||||
Discourse.ajax(`/posts/${this.get('id')}/${field}`, { type: 'PUT', data }).then(() => {
|
return Discourse.ajax(`/posts/${this.get('id')}/${field}`, { type: 'PUT', data }).then(() => {
|
||||||
this.set(field, value);
|
this.set(field, value);
|
||||||
this.incrementProperty("version");
|
this.incrementProperty("version");
|
||||||
}).catch(popupAjaxError);
|
}).catch(popupAjaxError);
|
||||||
|
@ -97,9 +76,6 @@ const Post = RestModel.extend({
|
||||||
return this.get('link_counts').filterProperty('internal').filterProperty('title');
|
return this.get('link_counts').filterProperty('internal').filterProperty('title');
|
||||||
}.property('link_counts.@each.internal'),
|
}.property('link_counts.@each.internal'),
|
||||||
|
|
||||||
// Edits are the version - 1, so version 2 = 1 edit
|
|
||||||
editCount: function() { return this.get('version') - 1; }.property('version'),
|
|
||||||
|
|
||||||
flagsAvailable: function() {
|
flagsAvailable: function() {
|
||||||
const post = this;
|
const post = this;
|
||||||
return Discourse.Site.currentProp('flagTypes').filter(function(item) {
|
return Discourse.Site.currentProp('flagTypes').filter(function(item) {
|
||||||
|
@ -107,17 +83,6 @@ const Post = RestModel.extend({
|
||||||
});
|
});
|
||||||
}.property('actions_summary.@each.can_act'),
|
}.property('actions_summary.@each.can_act'),
|
||||||
|
|
||||||
actionsWithoutLikes: function() {
|
|
||||||
if (!!Ember.isEmpty(this.get('actions_summary'))) return null;
|
|
||||||
|
|
||||||
return this.get('actions_summary').filter(function(i) {
|
|
||||||
if (i.get('count') === 0) return false;
|
|
||||||
if (i.get('actionType.name_key') === 'like') { return false; }
|
|
||||||
if (i.get('users') && i.get('users').length > 0) return true;
|
|
||||||
return !i.get('hidden');
|
|
||||||
});
|
|
||||||
}.property('actions_summary.@each.users', 'actions_summary.@each.count'),
|
|
||||||
|
|
||||||
afterUpdate(res) {
|
afterUpdate(res) {
|
||||||
if (res.category) {
|
if (res.category) {
|
||||||
Discourse.Site.current().updateCategory(res.category);
|
Discourse.Site.current().updateCategory(res.category);
|
||||||
|
@ -246,10 +211,6 @@ const Post = RestModel.extend({
|
||||||
let value = otherPost[key],
|
let value = otherPost[key],
|
||||||
oldValue = self[key];
|
oldValue = self[key];
|
||||||
|
|
||||||
if (key === "replyHistory") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) { value = null; }
|
if (!value) { value = null; }
|
||||||
if (!oldValue) { oldValue = null; }
|
if (!oldValue) { oldValue = null; }
|
||||||
|
|
||||||
|
@ -267,56 +228,9 @@ const Post = RestModel.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load replies to this post
|
|
||||||
loadReplies() {
|
|
||||||
if(this.get('loadingReplies')){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.set('loadingReplies', true);
|
|
||||||
this.set('replies', []);
|
|
||||||
|
|
||||||
const self = this;
|
|
||||||
return Discourse.ajax("/posts/" + (this.get('id')) + "/replies")
|
|
||||||
.then(function(loaded) {
|
|
||||||
const replies = self.get('replies');
|
|
||||||
_.each(loaded,function(reply) {
|
|
||||||
const post = Discourse.Post.create(reply);
|
|
||||||
post.set('topic', self.get('topic'));
|
|
||||||
replies.pushObject(post);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
['finally'](function(){
|
|
||||||
self.set('loadingReplies', false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Whether to show replies directly below
|
|
||||||
showRepliesBelow: function() {
|
|
||||||
const replyCount = this.get('reply_count');
|
|
||||||
|
|
||||||
// We don't show replies if there aren't any
|
|
||||||
if (replyCount === 0) return false;
|
|
||||||
|
|
||||||
// Always show replies if the setting `suppress_reply_directly_below` is false.
|
|
||||||
if (!Discourse.SiteSettings.suppress_reply_directly_below) return true;
|
|
||||||
|
|
||||||
// Always show replies if there's more than one
|
|
||||||
if (replyCount > 1) return true;
|
|
||||||
|
|
||||||
// If we have *exactly* one reply, we have to consider if it's directly below us
|
|
||||||
const topic = this.get('topic');
|
|
||||||
return !topic.isReplyDirectlyBelow(this);
|
|
||||||
|
|
||||||
}.property('reply_count'),
|
|
||||||
|
|
||||||
expandHidden() {
|
expandHidden() {
|
||||||
const self = this;
|
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(result => {
|
||||||
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) {
|
this.setProperties({ cooked: result.cooked, cooked_hidden: false });
|
||||||
self.setProperties({
|
|
||||||
cooked: result.cooked,
|
|
||||||
cooked_hidden: false
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,9 @@ function findAndRemoveMap(type, id) {
|
||||||
flushMap();
|
flushMap();
|
||||||
|
|
||||||
export default Ember.Object.extend({
|
export default Ember.Object.extend({
|
||||||
_plurals: {},
|
_plurals: {'post-reply': 'post-replies',
|
||||||
|
'post-reply-history': 'post_reply_histories'},
|
||||||
|
|
||||||
pluralize(thing) {
|
pluralize(thing) {
|
||||||
return this._plurals[thing] || thing + "s";
|
return this._plurals[thing] || thing + "s";
|
||||||
},
|
},
|
||||||
|
|
|
@ -34,12 +34,6 @@ const TopicDetails = RestModel.extend({
|
||||||
this.set('loaded', true);
|
this.set('loaded', true);
|
||||||
},
|
},
|
||||||
|
|
||||||
fewParticipants: function() {
|
|
||||||
if (!!Ember.isEmpty(this.get('participants'))) return null;
|
|
||||||
return this.get('participants').slice(0, 3);
|
|
||||||
}.property('participants'),
|
|
||||||
|
|
||||||
|
|
||||||
notificationReasonText: function() {
|
notificationReasonText: function() {
|
||||||
var level = this.get('notification_level');
|
var level = this.get('notification_level');
|
||||||
if(typeof level !== 'number'){
|
if(typeof level !== 'number'){
|
||||||
|
@ -68,13 +62,13 @@ const TopicDetails = RestModel.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
removeAllowedUser(user) {
|
removeAllowedUser(user) {
|
||||||
var users = this.get('allowed_users'),
|
const users = this.get('allowed_users');
|
||||||
username = user.get('username');
|
const username = user.get('username');
|
||||||
|
|
||||||
Discourse.ajax("/t/" + this.get('topic.id') + "/remove-allowed-user", {
|
return Discourse.ajax("/t/" + this.get('topic.id') + "/remove-allowed-user", {
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
data: { username: username }
|
data: { username: username }
|
||||||
}).then(function() {
|
}).then(() => {
|
||||||
users.removeObject(users.findProperty('username', username));
|
users.removeObject(users.findProperty('username', username));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -224,13 +224,6 @@ const Topic = RestModel.extend({
|
||||||
.then(function () { self.set('archetype', 'regular'); });
|
.then(function () { self.set('archetype', 'regular'); });
|
||||||
},
|
},
|
||||||
|
|
||||||
estimatedReadingTime: function() {
|
|
||||||
const wordCount = this.get('word_count');
|
|
||||||
if (!wordCount) return;
|
|
||||||
|
|
||||||
return Math.floor(wordCount / Discourse.SiteSettings.read_time_word_count);
|
|
||||||
}.property('word_count'),
|
|
||||||
|
|
||||||
toggleBookmark() {
|
toggleBookmark() {
|
||||||
if (this.get("bookmarking")) { return; }
|
if (this.get("bookmarking")) { return; }
|
||||||
this.set("bookmarking", true);
|
this.set("bookmarking", true);
|
||||||
|
@ -390,25 +383,6 @@ const Topic = RestModel.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Is the reply to a post directly below it?
|
|
||||||
isReplyDirectlyBelow(post) {
|
|
||||||
const posts = this.get('postStream.posts');
|
|
||||||
const postNumber = post.get('post_number');
|
|
||||||
if (!posts) return;
|
|
||||||
|
|
||||||
const postBelow = posts[posts.indexOf(post) + 1];
|
|
||||||
|
|
||||||
// If the post directly below's reply_to_post_number is our post number or we are quoted,
|
|
||||||
// it's considered directly below.
|
|
||||||
//
|
|
||||||
// TODO: we don't carry information about quoting, this leaves this code fairly fragile
|
|
||||||
// instead we should start shipping quote meta data with posts, but this will add at least
|
|
||||||
// 1 query to the topics page
|
|
||||||
//
|
|
||||||
return postBelow && (postBelow.get('reply_to_post_number') === postNumber ||
|
|
||||||
postBelow.get('cooked').indexOf('data-post="'+ postNumber + '"') >= 0
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
hasExcerpt: Em.computed.notEmpty('excerpt'),
|
hasExcerpt: Em.computed.notEmpty('excerpt'),
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import DiscourseURL from 'discourse/lib/url';
|
||||||
import DiscourseLocation from 'discourse/lib/discourse-location';
|
import DiscourseLocation from 'discourse/lib/discourse-location';
|
||||||
import SearchService from 'discourse/services/search';
|
import SearchService from 'discourse/services/search';
|
||||||
import { startTracking, default as TopicTrackingState } from 'discourse/models/topic-tracking-state';
|
import { startTracking, default as TopicTrackingState } from 'discourse/models/topic-tracking-state';
|
||||||
|
import ScreenTrack from 'discourse/lib/screen-track';
|
||||||
|
|
||||||
function inject() {
|
function inject() {
|
||||||
const app = arguments[0],
|
const app = arguments[0],
|
||||||
|
@ -38,23 +39,29 @@ export default {
|
||||||
const currentUser = Discourse.User.current();
|
const currentUser = Discourse.User.current();
|
||||||
app.register('current-user:main', currentUser, { instantiate: false });
|
app.register('current-user:main', currentUser, { instantiate: false });
|
||||||
|
|
||||||
const tracking = TopicTrackingState.create({ messageBus, currentUser });
|
const topicTrackingState = TopicTrackingState.create({ messageBus, currentUser });
|
||||||
app.register('topic-tracking-state:main', tracking, { instantiate: false });
|
app.register('topic-tracking-state:main', topicTrackingState, { instantiate: false });
|
||||||
injectAll(app, 'topicTrackingState');
|
injectAll(app, 'topicTrackingState');
|
||||||
|
|
||||||
const site = Discourse.Site.current();
|
const site = Discourse.Site.current();
|
||||||
app.register('site:main', site, { instantiate: false });
|
app.register('site:main', site, { instantiate: false });
|
||||||
injectAll(app, 'site');
|
injectAll(app, 'site');
|
||||||
|
|
||||||
app.register('site-settings:main', Discourse.SiteSettings, { instantiate: false });
|
const siteSettings = Discourse.SiteSettings;
|
||||||
|
app.register('site-settings:main', siteSettings, { instantiate: false });
|
||||||
injectAll(app, 'siteSettings');
|
injectAll(app, 'siteSettings');
|
||||||
|
|
||||||
app.register('search-service:main', SearchService);
|
app.register('search-service:main', SearchService);
|
||||||
injectAll(app, 'searchService');
|
injectAll(app, 'searchService');
|
||||||
|
|
||||||
app.register('session:main', Session.current(), { instantiate: false });
|
const session = Session.current();
|
||||||
|
app.register('session:main', session, { instantiate: false });
|
||||||
injectAll(app, 'session');
|
injectAll(app, 'session');
|
||||||
|
|
||||||
|
const screenTrack = new ScreenTrack(topicTrackingState, siteSettings, session, currentUser);
|
||||||
|
app.register('screen-track:main', screenTrack, { instantiate: false });
|
||||||
|
inject(app, 'screenTrack', 'component', 'route');
|
||||||
|
|
||||||
inject(app, 'currentUser', 'component', 'route', 'controller');
|
inject(app, 'currentUser', 'component', 'route', 'controller');
|
||||||
|
|
||||||
app.register('location:discourse-location', DiscourseLocation);
|
app.register('location:discourse-location', DiscourseLocation);
|
||||||
|
@ -63,6 +70,6 @@ export default {
|
||||||
app.register('key-value-store:main', keyValueStore, { instantiate: false });
|
app.register('key-value-store:main', keyValueStore, { instantiate: false });
|
||||||
injectAll(app, 'keyValueStore');
|
injectAll(app, 'keyValueStore');
|
||||||
|
|
||||||
startTracking(tracking);
|
startTracking(topicTrackingState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -37,12 +37,6 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, {
|
||||||
return this._super();
|
return this._super();
|
||||||
},
|
},
|
||||||
|
|
||||||
// This is here as a bugfix for when an Ember Cloaked view triggers
|
|
||||||
// a scroll after a controller has been torn down. The real fix
|
|
||||||
// should be to fix ember cloaking to not do that, but this catches
|
|
||||||
// it safely just in case.
|
|
||||||
postChangedRoute: Ember.K,
|
|
||||||
|
|
||||||
showTopicEntrance(data) {
|
showTopicEntrance(data) {
|
||||||
this.controllerFor('topic-entrance').send('show', data);
|
this.controllerFor('topic-entrance').send('show', data);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import ScreenTrack from 'discourse/lib/screen-track';
|
|
||||||
import { queryParams } from 'discourse/controllers/discovery-sortable';
|
import { queryParams } from 'discourse/controllers/discovery-sortable';
|
||||||
|
|
||||||
// A helper to build a topic route for a filter
|
// A helper to build a topic route for a filter
|
||||||
|
@ -69,7 +68,7 @@ export default function(filter, extras) {
|
||||||
|
|
||||||
model(data, transition) {
|
model(data, transition) {
|
||||||
// attempt to stop early cause we need this to be called before .sync
|
// attempt to stop early cause we need this to be called before .sync
|
||||||
ScreenTrack.current().stop();
|
this.screenTrack.stop();
|
||||||
|
|
||||||
const findOpts = filterQueryParams(data),
|
const findOpts = filterQueryParams(data),
|
||||||
findExtras = { cached: this.isPoppedState(transition) };
|
findExtras = { cached: this.isPoppedState(transition) };
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import ScreenTrack from 'discourse/lib/screen-track';
|
|
||||||
import DiscourseURL from 'discourse/lib/url';
|
import DiscourseURL from 'discourse/lib/url';
|
||||||
|
|
||||||
let isTransitioning = false,
|
let isTransitioning = false,
|
||||||
|
@ -186,7 +185,7 @@ const TopicRoute = Discourse.Route.extend({
|
||||||
topicController.set('multiSelect', false);
|
topicController.set('multiSelect', false);
|
||||||
topicController.unsubscribe();
|
topicController.unsubscribe();
|
||||||
this.controllerFor('composer').set('topic', null);
|
this.controllerFor('composer').set('topic', null);
|
||||||
ScreenTrack.current().stop();
|
this.screenTrack.stop();
|
||||||
|
|
||||||
const headerController = this.controllerFor('header');
|
const headerController = this.controllerFor('header');
|
||||||
if (headerController) {
|
if (headerController) {
|
||||||
|
@ -215,8 +214,9 @@ const TopicRoute = Discourse.Route.extend({
|
||||||
controller.subscribe();
|
controller.subscribe();
|
||||||
|
|
||||||
this.controllerFor('topic-progress').set('model', model);
|
this.controllerFor('topic-progress').set('model', model);
|
||||||
|
|
||||||
// We reset screen tracking every time a topic is entered
|
// We reset screen tracking every time a topic is entered
|
||||||
ScreenTrack.current().start(model.get('id'), controller);
|
this.screenTrack.start(model.get('id'), controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
<h3>{{fa-icon 'envelope'}} {{i18n 'private_message_info.title'}}</h3>
|
|
||||||
<div class='participants clearfix'>
|
|
||||||
{{#each details.allowed_groups as |ag|}}
|
|
||||||
<div class='user group'>
|
|
||||||
{{fa-icon 'users'}} {{#link-to "group.index" ag.name}}{{unbound ag.name}}{{/link-to}}
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
{{#each details.allowed_users as |au|}}
|
|
||||||
<div class='user'>
|
|
||||||
{{#user-link user=au}}
|
|
||||||
{{avatar au imageSize="small"}}
|
|
||||||
{{unbound au.username}}
|
|
||||||
{{/user-link}}
|
|
||||||
{{#if details.can_remove_allowed_users}}
|
|
||||||
<a href class='remove-invited' {{action "removeAllowedUser" au}}>{{fa-icon "times"}}</a>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
</div>
|
|
||||||
{{#if details.can_invite_to}}
|
|
||||||
<div class='controls'>
|
|
||||||
<button class='btn' {{action "showPrivateInvite"}}>{{i18n 'private_message_info.invite'}}</button>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
|
@ -1,4 +1,3 @@
|
||||||
<div class='topic-avatar'>{{fa-icon icon}}</div>
|
|
||||||
<div class='small-action-desc'>
|
<div class='small-action-desc'>
|
||||||
{{#if post}}
|
{{#if post}}
|
||||||
{{#if post.can_delete}}
|
{{#if post.can_delete}}
|
||||||
|
@ -11,8 +10,4 @@
|
||||||
{{avatar post imageSize="small"}}
|
{{avatar post imageSize="small"}}
|
||||||
</a>
|
</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<p>{{description}}</p>
|
|
||||||
{{#if post.cooked}}
|
|
||||||
<div class='custom-message'>{{{post.cooked}}}</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
{{#if postStream.summary}}
|
|
||||||
<p>{{{i18n 'summary.enabled_description'}}}</p>
|
|
||||||
<button class='btn btn-primary' {{action "toggleSummary"}}>{{i18n 'summary.disable'}}</button>
|
|
||||||
{{else}}
|
|
||||||
{{#if topic.estimatedReadingTime}}
|
|
||||||
<p>{{{i18n 'summary.description_time' count=topic.posts_count readingTime=topic.estimatedReadingTime}}}</p>
|
|
||||||
{{else}}
|
|
||||||
<p>{{{i18n 'summary.description' count=topic.posts_count}}}</p>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<button class='btn btn-primary' {{action "toggleSummary"}}>{{i18n 'summary.enable'}}</button>
|
|
||||||
{{/if}}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<a href {{bind-attr class=":poster toggled"}} {{action "toggle"}} title={{unbound participant.username}}>
|
|
||||||
{{#if showPostCount}}
|
|
||||||
<span class='post-count'>{{unbound participant.post_count}}</span>
|
|
||||||
{{/if}}
|
|
||||||
{{avatar participant imageSize="medium"}}
|
|
||||||
</a>
|
|
|
@ -1,16 +0,0 @@
|
||||||
<div class='row'>
|
|
||||||
<div class='topic-avatar'>
|
|
||||||
{{raw "post/poster-avatar" post=this classNames="main-avatar"}}
|
|
||||||
</div>
|
|
||||||
<div class='topic-body'>
|
|
||||||
<div class="topic-meta-data">
|
|
||||||
{{poster-name post=this}}
|
|
||||||
{{#if view.parentView.previousPost}}<a href='{{unbound url}}' class="post-info arrow" title="{{i18n 'topic.jump_reply_up'}}"><i class='fa fa-arrow-up'></i></a>{{/if}}
|
|
||||||
{{#unless view.parentView.previousPost}}<a href='{{unbound url}}' class="post-info arrow" title="{{i18n 'topic.jump_reply_down'}}"><i class='fa fa-arrow-down'></i></a>{{/unless}}
|
|
||||||
<a href='{{unbound url}}'><div class='post-info post-date'>{{age-with-tooltip created_at}}</div></a>
|
|
||||||
</div>
|
|
||||||
<div class='cooked'>
|
|
||||||
{{{unbound cooked}}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,8 +0,0 @@
|
||||||
<div class="modal-body">
|
|
||||||
<form>
|
|
||||||
{{view "archetype-options" archetype=view.archetype}}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class='btn btn-primary' {{action "closeModal"}}>{{i18n 'post.archetypes.save'}}</button>
|
|
||||||
</div>
|
|
|
@ -1,9 +0,0 @@
|
||||||
{{post-gap post=this postStream=controller.model.postStream before="true"}}
|
|
||||||
|
|
||||||
{{small-action actionCode=action_code
|
|
||||||
post=this
|
|
||||||
daysAgo=view.daysAgo
|
|
||||||
editPost="editPost"
|
|
||||||
deletePost="deletePost"}}
|
|
||||||
|
|
||||||
{{post-gap post=this postStream=controller.model.postStream before="false"}}
|
|
|
@ -1,141 +0,0 @@
|
||||||
{{post-gap post=this postStream=controller.model.postStream before="true"}}
|
|
||||||
|
|
||||||
{{#if hasTimeGap}}
|
|
||||||
{{time-gap daysAgo=daysSincePrevious postStream=controller.model.postStream}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<div class='row'>
|
|
||||||
{{view 'reply-history' content=replyHistory}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="boxed {{if via_email 'via-email'}}" id={{view.postElementId}} data-post-id={{id}} data-user-id={{user_id}}>
|
|
||||||
<div class='row'>
|
|
||||||
|
|
||||||
<div class="topic-avatar">
|
|
||||||
{{#if userDeleted}}
|
|
||||||
<i class="fa fa-trash-o deleted-user-avatar"></i>
|
|
||||||
{{else}}
|
|
||||||
{{raw "post/poster-avatar" post=this classNames="main-avatar"}}
|
|
||||||
{{/if}}
|
|
||||||
<div class="poster-avatar-extra"></div>
|
|
||||||
{{plugin-outlet "poster-avatar-bottom"}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='topic-body'>
|
|
||||||
<div class='topic-meta-data'>
|
|
||||||
{{poster-name post=this}}
|
|
||||||
{{plugin-outlet "poster-name-right"}}
|
|
||||||
<div class='post-info'>
|
|
||||||
<a class='post-date' {{bind-attr href="shareUrl" data-share-url="shareUrl" data-post-number="post_number"}}>{{age-with-tooltip created_at}}</a>
|
|
||||||
</div>
|
|
||||||
{{#if hasHistory}}
|
|
||||||
<div class='post-info edits'>
|
|
||||||
{{#if can_view_edit_history}}
|
|
||||||
<a href class="{{unbound view.historyHeat}}" {{action "showHistory" this}} title="{{i18n 'post.last_edited_on'}} {{raw-date updated_at}}">
|
|
||||||
{{editCount}}
|
|
||||||
{{fa-icon "pencil"}}
|
|
||||||
</a>
|
|
||||||
{{else}}
|
|
||||||
<span class="{{unbound view.historyHeat}}" title="{{i18n 'post.last_edited_on'}} {{raw-date updated_at}}">
|
|
||||||
{{editCount}}
|
|
||||||
{{fa-icon "pencil"}}
|
|
||||||
</span>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{#if wiki}}
|
|
||||||
<div class="post-info wiki" title={{i18n 'post.wiki.about'}} {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
|
|
||||||
{{/if}}
|
|
||||||
{{#if via_email}}
|
|
||||||
{{#if canViewRawEmail}}
|
|
||||||
<div class="post-info via-email raw-email" title={{i18n 'post.via_email'}} {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="post-info via-email" title={{i18n 'post.via_email'}}>{{fa-icon "envelope-o"}}</div>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if view.whisper}}
|
|
||||||
<div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "eye-slash"}}</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if showUserReplyTab}}
|
|
||||||
<a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'>
|
|
||||||
{{#if loadingReplyHistory}}
|
|
||||||
{{i18n 'loading'}}
|
|
||||||
{{else}}
|
|
||||||
{{fa-icon "mail-forward"}}
|
|
||||||
{{avatar reply_to_user imageSize="tiny"}}
|
|
||||||
<span>{{reply_to_user.username}}</span>
|
|
||||||
{{/if}}
|
|
||||||
</a>
|
|
||||||
{{/if}}
|
|
||||||
<div {{bind-attr class=":read-state read"}} title="{{i18n 'post.unread'}}">{{fa-icon "circle"}}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="select-posts {{unless controller.multiSelect 'hidden'}}">
|
|
||||||
<button {{action "toggledSelectedPostReplies" this}} class="{{unless view.canSelectReplies 'hidden'}}">{{i18n 'topic.multi_select.select_replies'}}</button>
|
|
||||||
<button {{action "toggledSelectedPost" this}} class="select-post">{{view.selectPostText}}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- keep the classes here in sync with composer.hbs -->
|
|
||||||
<div {{bind-attr class="showUserReplyTab:avoid-tab view.repliesShown::contents :regular view.extraClass"}}>
|
|
||||||
<div class='cooked'>
|
|
||||||
{{{cooked}}}
|
|
||||||
{{plugin-outlet "post-after-cooked"}}
|
|
||||||
{{#if firstPost}}
|
|
||||||
{{plugin-outlet "topic-after-cooked"}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{#if cooked_hidden}}
|
|
||||||
<a href {{action "expandHidden" this}}>{{i18n 'post.show_hidden'}}</a>
|
|
||||||
{{/if}}
|
|
||||||
{{#if view.showExpandButton}}
|
|
||||||
{{#if controller.loadingExpanded}}
|
|
||||||
<button class="btn expand-post" disabled>{{i18n 'loading'}}</button>
|
|
||||||
{{else}}
|
|
||||||
<button {{action "expandFirstPost" this}} class='btn expand-post'>{{i18n 'post.show_full'}}…</button>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{post-menu post=this
|
|
||||||
canCreatePost=controller.model.details.can_create_post
|
|
||||||
replyToPost="replyToPost"
|
|
||||||
recoverPost="recoverPost"
|
|
||||||
deletePost="deletePost"
|
|
||||||
toggleLike="toggleLike"
|
|
||||||
toggleLikeTarget=view
|
|
||||||
showFlags="showFlags"
|
|
||||||
editPost="editPost"
|
|
||||||
toggleBookmark="toggleBookmark"
|
|
||||||
toggleWiki="toggleWiki"
|
|
||||||
togglePostType="togglePostType"
|
|
||||||
rebakePost="rebakePost"
|
|
||||||
unhidePost="unhidePost"
|
|
||||||
changePostOwner="changePostOwner"
|
|
||||||
toggleWhoLiked="toggleWhoLiked"
|
|
||||||
toggleWhoLikedTarget=view}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{who-liked users=view.likedUsers}}
|
|
||||||
{{#if replies}}
|
|
||||||
<section class='embedded-posts bottom'>
|
|
||||||
{{#each reply in replies}}
|
|
||||||
{{view 'embedded-post' content=reply}}
|
|
||||||
{{/each}}
|
|
||||||
</section>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{actions-summary post=this}}
|
|
||||||
{{view 'topic-map-container' post=this topic=controller.model}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{post-gutter post=this
|
|
||||||
links=internalLinks
|
|
||||||
canReplyAsNewTopic=topic.details.can_reply_as_new_topic
|
|
||||||
newTopicAction="replyAsNewTopic"}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{{post-gap post=this postStream=controller.model.postStream before="false"}}
|
|
||||||
{{plugin-outlet "post-bottom"}}
|
|
|
@ -69,15 +69,39 @@
|
||||||
{{conditional-loading-spinner condition=model.postStream.loadingAbove}}
|
{{conditional-loading-spinner condition=model.postStream.loadingAbove}}
|
||||||
|
|
||||||
{{#unless model.postStream.loadingFilter}}
|
{{#unless model.postStream.loadingFilter}}
|
||||||
{{cloaked-collection itemViewClass="post"
|
{{scrolling-post-stream
|
||||||
defaultHeight="200"
|
posts=postsToRender
|
||||||
content=postsToRender
|
canCreatePost=model.details.can_create_post
|
||||||
slackRatio="15"
|
multiSelect=multiSelect
|
||||||
loadingHTML=""
|
selectedPostsCount=selectedPostsCount
|
||||||
preservesContext="true"
|
selectedQuery=selectedQuery
|
||||||
uncloakDefault="true"
|
gaps=model.postStream.gaps
|
||||||
offsetFixedTop="header"
|
showFlags="showFlags"
|
||||||
offsetFixedBottom="#reply-control"}}
|
editPost="editPost"
|
||||||
|
showHistory="showHistory"
|
||||||
|
showRawEmail="showRawEmail"
|
||||||
|
deletePost="deletePost"
|
||||||
|
recoverPost="recoverPost"
|
||||||
|
expandHidden="expandHidden"
|
||||||
|
newTopicAction="replyAsNewTopic"
|
||||||
|
expandFirstPost="expandFirstPost"
|
||||||
|
toggleBookmark="toggleBookmark"
|
||||||
|
togglePostType="togglePostType"
|
||||||
|
rebakePost="rebakePost"
|
||||||
|
changePostOwner="changePostOwner"
|
||||||
|
unhidePost="unhidePost"
|
||||||
|
replyToPost="replyToPost"
|
||||||
|
toggleWiki="toggleWiki"
|
||||||
|
toggleParticipant="toggleParticipant"
|
||||||
|
toggleSummary="toggleSummary"
|
||||||
|
removeAllowedUser="removeAllowedUser"
|
||||||
|
showInvite="showInvite"
|
||||||
|
topVisibleChanged="topVisibleChanged"
|
||||||
|
bottomVisibleChanged="bottomVisibleChanged"
|
||||||
|
selectPost="toggledSelectedPost"
|
||||||
|
selectReplies="toggledSelectedPostReplies"
|
||||||
|
fillGapBefore="fillGapBefore"
|
||||||
|
fillGapAfter="fillGapAfter"}}
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
</div>
|
</div>
|
||||||
<div id="topic-bottom"></div>
|
<div id="topic-bottom"></div>
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import DiscourseContainerView from 'discourse/views/container';
|
|
||||||
|
|
||||||
export default DiscourseContainerView.extend({
|
|
||||||
metaDataBinding: 'parentView.metaData',
|
|
||||||
|
|
||||||
init: function() {
|
|
||||||
this._super();
|
|
||||||
var metaData = this.get('metaData');
|
|
||||||
var archetypeOptionsView = this;
|
|
||||||
return this.get('archetype.options').forEach(function(a) {
|
|
||||||
if (a.option_type === 1) {
|
|
||||||
archetypeOptionsView.attachViewWithArgs({
|
|
||||||
content: a,
|
|
||||||
checked: metaData.get(a.key) === 'true'
|
|
||||||
}, Discourse.OptionBooleanView);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,300 +0,0 @@
|
||||||
/*eslint no-bitwise:0 */
|
|
||||||
const CloakedCollectionView = Ember.CollectionView.extend({
|
|
||||||
cloakView: Ember.computed.alias('itemViewClass'),
|
|
||||||
topVisible: null,
|
|
||||||
bottomVisible: null,
|
|
||||||
offsetFixedTopElement: null,
|
|
||||||
offsetFixedBottomElement: null,
|
|
||||||
loadingHTML: 'Loading...',
|
|
||||||
scrollDebounce: 10,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
const cloakView = this.get('cloakView'),
|
|
||||||
idProperty = this.get('idProperty'),
|
|
||||||
uncloakDefault = !!this.get('uncloakDefault');
|
|
||||||
|
|
||||||
// Set the slack ratio differently to allow for more or less slack in preloading
|
|
||||||
const slackRatio = parseFloat(this.get('slackRatio'));
|
|
||||||
if (!slackRatio) { this.set('slackRatio', 1.0); }
|
|
||||||
|
|
||||||
const CloakedView = this.container.lookupFactory('view:cloaked');
|
|
||||||
this.set('itemViewClass', CloakedView.extend({
|
|
||||||
classNames: [cloakView + '-cloak'],
|
|
||||||
cloaks: cloakView,
|
|
||||||
preservesContext: this.get('preservesContext') === 'true',
|
|
||||||
cloaksController: this.get('itemController'),
|
|
||||||
defaultHeight: this.get('defaultHeight'),
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super();
|
|
||||||
if (idProperty) {
|
|
||||||
this.set('elementId', cloakView + '-cloak-' + this.get('content.' + idProperty));
|
|
||||||
}
|
|
||||||
if (uncloakDefault) {
|
|
||||||
this.uncloak();
|
|
||||||
} else {
|
|
||||||
this.cloak();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
this._super();
|
|
||||||
Ember.run.next(this, 'scrolled');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
If the topmost visible view changed, we will notify the controller if it has an appropriate hook.
|
|
||||||
|
|
||||||
@method _topVisibleChanged
|
|
||||||
@observes topVisible
|
|
||||||
**/
|
|
||||||
_topVisibleChanged: function() {
|
|
||||||
const controller = this.get('controller');
|
|
||||||
if (controller.topVisibleChanged) { controller.topVisibleChanged(this.get('topVisible')); }
|
|
||||||
}.observes('topVisible'),
|
|
||||||
|
|
||||||
/**
|
|
||||||
If the bottommost visible view changed, we will notify the controller if it has an appropriate hook.
|
|
||||||
|
|
||||||
@method _bottomVisible
|
|
||||||
@observes bottomVisible
|
|
||||||
**/
|
|
||||||
_bottomVisible: function() {
|
|
||||||
const controller = this.get('controller');
|
|
||||||
if (controller.bottomVisibleChanged) { controller.bottomVisibleChanged(this.get('bottomVisible')); }
|
|
||||||
}.observes('bottomVisible'),
|
|
||||||
|
|
||||||
/**
|
|
||||||
Binary search for finding the topmost view on screen.
|
|
||||||
|
|
||||||
@method findTopView
|
|
||||||
@param {Array} childViews the childViews to search through
|
|
||||||
@param {Number} windowTop The top of the viewport to search against
|
|
||||||
@param {Number} min The minimum index to search through of the child views
|
|
||||||
@param {Number} max The max index to search through of the child views
|
|
||||||
@returns {Number} the index into childViews of the topmost view
|
|
||||||
**/
|
|
||||||
findTopView(childViews, viewportTop, min, max) {
|
|
||||||
if (max < min) { return min; }
|
|
||||||
|
|
||||||
const wrapperTop = this.get('wrapperTop')>>0;
|
|
||||||
|
|
||||||
while(max>min){
|
|
||||||
const mid = Math.floor((min + max) / 2),
|
|
||||||
// in case of not full-window scrolling
|
|
||||||
$view = childViews[mid].$(),
|
|
||||||
|
|
||||||
// .position is quite expensive, shortcut here to get a slightly rougher
|
|
||||||
// but much faster value
|
|
||||||
parentOffsetTop = $view.offsetParent().offset().top,
|
|
||||||
offsetTop = $view.offset().top,
|
|
||||||
viewBottom = (offsetTop - parentOffsetTop) + wrapperTop + $view.height();
|
|
||||||
|
|
||||||
if (viewBottom > viewportTop) {
|
|
||||||
max = mid-1;
|
|
||||||
} else {
|
|
||||||
min = mid+1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return min;
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
Determine what views are onscreen and cloak/uncloak them as necessary.
|
|
||||||
|
|
||||||
@method scrolled
|
|
||||||
**/
|
|
||||||
scrolled() {
|
|
||||||
if (!this.get('scrollingEnabled')) { return; }
|
|
||||||
|
|
||||||
const childViews = this.get('childViews');
|
|
||||||
if ((!childViews) || (childViews.length === 0)) { return; }
|
|
||||||
|
|
||||||
const self = this,
|
|
||||||
toUncloak = [],
|
|
||||||
onscreen = [],
|
|
||||||
onscreenCloaks = [],
|
|
||||||
$w = $(window),
|
|
||||||
windowHeight = this.get('wrapperHeight') || ( window.innerHeight ? window.innerHeight : $w.height() ),
|
|
||||||
slack = Math.round(windowHeight * this.get('slackRatio')),
|
|
||||||
offsetFixedTopElement = this.get('offsetFixedTopElement'),
|
|
||||||
offsetFixedBottomElement = this.get('offsetFixedBottomElement'),
|
|
||||||
bodyHeight = this.get('wrapperHeight') ? this.$().height() : $('body').height();
|
|
||||||
|
|
||||||
let windowTop = this.get('wrapperTop') || $w.scrollTop();
|
|
||||||
|
|
||||||
const viewportTop = windowTop - slack,
|
|
||||||
topView = this.findTopView(childViews, viewportTop, 0, childViews.length-1);
|
|
||||||
|
|
||||||
let windowBottom = windowTop + windowHeight;
|
|
||||||
let viewportBottom = windowBottom + slack;
|
|
||||||
|
|
||||||
if (windowBottom > bodyHeight) { windowBottom = bodyHeight; }
|
|
||||||
if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; }
|
|
||||||
|
|
||||||
if (offsetFixedTopElement) {
|
|
||||||
windowTop += (offsetFixedTopElement.outerHeight(true) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offsetFixedBottomElement) {
|
|
||||||
windowBottom -= (offsetFixedBottomElement.outerHeight(true) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the bottom view and what's onscreen
|
|
||||||
let bottomView = topView;
|
|
||||||
let bottomVisible = null;
|
|
||||||
while (bottomView < childViews.length) {
|
|
||||||
const view = childViews[bottomView];
|
|
||||||
const $view = view.$();
|
|
||||||
|
|
||||||
if (!$view) { break; }
|
|
||||||
|
|
||||||
// in case of not full-window scrolling
|
|
||||||
const scrollOffset = this.get('wrapperTop') || 0;
|
|
||||||
const viewTop = $view.offset().top + scrollOffset;
|
|
||||||
const viewBottom = viewTop + $view.height();
|
|
||||||
|
|
||||||
if (viewTop > viewportBottom) { break; }
|
|
||||||
toUncloak.push(view);
|
|
||||||
|
|
||||||
if (viewBottom > windowTop && viewTop <= windowBottom) {
|
|
||||||
const content = view.get('content');
|
|
||||||
onscreen.push(content);
|
|
||||||
|
|
||||||
if (!view.get('isPlaceholder')) {
|
|
||||||
bottomVisible = content;
|
|
||||||
}
|
|
||||||
onscreenCloaks.push(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
bottomView++;
|
|
||||||
}
|
|
||||||
if (bottomView >= childViews.length) { bottomView = childViews.length - 1; }
|
|
||||||
|
|
||||||
// If our controller has a `sawObjects` method, pass the on screen objects to it.
|
|
||||||
const controller = this.get('controller');
|
|
||||||
if (onscreen.length) {
|
|
||||||
this.setProperties({topVisible: onscreen[0], bottomVisible });
|
|
||||||
if (controller && controller.sawObjects) {
|
|
||||||
Em.run.schedule('afterRender', function() {
|
|
||||||
controller.sawObjects(onscreen);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.setProperties({topVisible: null, bottomVisible: null});
|
|
||||||
}
|
|
||||||
|
|
||||||
const toCloak = childViews.slice(0, topView).concat(childViews.slice(bottomView+1));
|
|
||||||
|
|
||||||
this._uncloak = toUncloak;
|
|
||||||
if(this._nextUncloak){
|
|
||||||
Em.run.cancel(this._nextUncloak);
|
|
||||||
this._nextUncloak = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Em.run.schedule('afterRender', this, function() {
|
|
||||||
onscreenCloaks.forEach(function (v) {
|
|
||||||
if(v && v.uncloak) {
|
|
||||||
v.uncloak();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
toCloak.forEach(function (v) { v.cloak(); });
|
|
||||||
if (self._nextUncloak) { Em.run.cancel(self._nextUncloak); }
|
|
||||||
self._nextUncloak = Em.run.later(self, self.uncloakQueue,50);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let j=bottomView; j<childViews.length; j++) {
|
|
||||||
const checkView = childViews[j];
|
|
||||||
if (!checkView._containedView) {
|
|
||||||
const loadingHTML = this.get('loadingHTML');
|
|
||||||
if (!Em.isEmpty(loadingHTML) && !checkView.get('loading')) {
|
|
||||||
checkView.$().html(loadingHTML);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
uncloakQueue() {
|
|
||||||
const maxPerRun = 3, delay = 50, self = this;
|
|
||||||
let processed = 0;
|
|
||||||
|
|
||||||
if(this._uncloak){
|
|
||||||
while(processed < maxPerRun && this._uncloak.length>0){
|
|
||||||
const view = this._uncloak.shift();
|
|
||||||
if(view && view.uncloak && !view._containedView){
|
|
||||||
Em.run.schedule('afterRender', view, view.uncloak);
|
|
||||||
processed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(this._uncloak.length === 0){
|
|
||||||
this._uncloak = null;
|
|
||||||
} else {
|
|
||||||
Em.run.schedule('afterRender', self, function(){
|
|
||||||
if(self._nextUncloak){
|
|
||||||
Em.run.cancel(self._nextUncloak);
|
|
||||||
}
|
|
||||||
self._nextUncloak = Em.run.next(self, function(){
|
|
||||||
if(self._nextUncloak){
|
|
||||||
Em.run.cancel(self._nextUncloak);
|
|
||||||
}
|
|
||||||
self._nextUncloak = Em.run.later(self,self.uncloakQueue,delay);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scrollTriggered() {
|
|
||||||
if ($('body').data('disable-cloaked-view')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Em.run.scheduleOnce('afterRender', this, 'scrolled');
|
|
||||||
},
|
|
||||||
|
|
||||||
_startEvents: function() {
|
|
||||||
if (this.get('offsetFixed')) {
|
|
||||||
Em.warn("Cloaked-collection's `offsetFixed` is deprecated. Use `offsetFixedTop` instead.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const self = this,
|
|
||||||
offsetFixedTop = this.get('offsetFixedTop') || this.get('offsetFixed'),
|
|
||||||
offsetFixedBottom = this.get('offsetFixedBottom'),
|
|
||||||
scrollDebounce = this.get('scrollDebounce'),
|
|
||||||
onScrollMethod = function() {
|
|
||||||
Ember.run.debounce(self, 'scrollTriggered', scrollDebounce);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (offsetFixedTop) {
|
|
||||||
this.set('offsetFixedTopElement', $(offsetFixedTop));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offsetFixedBottom) {
|
|
||||||
this.set('offsetFixedBottomElement', $(offsetFixedBottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).bind('touchmove.ember-cloak', onScrollMethod);
|
|
||||||
$(window).bind('scroll.ember-cloak', onScrollMethod);
|
|
||||||
this.addObserver('wrapperTop', self, onScrollMethod);
|
|
||||||
this.addObserver('wrapperHeight', self, onScrollMethod);
|
|
||||||
this.addObserver('content.@each', self, onScrollMethod);
|
|
||||||
this.scrollTriggered();
|
|
||||||
|
|
||||||
this.set('scrollingEnabled', true);
|
|
||||||
}.on('didInsertElement'),
|
|
||||||
|
|
||||||
cleanUp() {
|
|
||||||
$(document).unbind('touchmove.ember-cloak');
|
|
||||||
$(window).unbind('scroll.ember-cloak');
|
|
||||||
this.set('scrollingEnabled', false);
|
|
||||||
},
|
|
||||||
|
|
||||||
_endEvents: function() {
|
|
||||||
this.cleanUp();
|
|
||||||
}.on('willDestroyElement')
|
|
||||||
});
|
|
||||||
|
|
||||||
Ember.Handlebars.helper('cloaked-collection', Ember.testing ? Ember.CollectionView : CloakedCollectionView);
|
|
||||||
export default CloakedCollectionView;
|
|
|
@ -1,143 +0,0 @@
|
||||||
export function Placeholder(viewName) {
|
|
||||||
this.viewName = viewName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Ember.View.extend({
|
|
||||||
attributeBindings: ['style'],
|
|
||||||
_containedView: null,
|
|
||||||
_scheduled: null,
|
|
||||||
isPlaceholder: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super();
|
|
||||||
this._scheduled = false;
|
|
||||||
this._childViews = [];
|
|
||||||
},
|
|
||||||
|
|
||||||
setContainedView(cv) {
|
|
||||||
if (this._childViews[0]) {
|
|
||||||
this._childViews[0].destroy();
|
|
||||||
this._childViews[0] = cv;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.set('isPlaceholder', cv && (cv.get('content') instanceof Placeholder));
|
|
||||||
|
|
||||||
if (cv) {
|
|
||||||
cv.set('_parentView', this);
|
|
||||||
cv.set('templateData', this.get('templateData'));
|
|
||||||
this._childViews[0] = cv;
|
|
||||||
} else {
|
|
||||||
this._childViews.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._scheduled) return;
|
|
||||||
this._scheduled = true;
|
|
||||||
this.set('_containedView', cv);
|
|
||||||
Ember.run.schedule('render', this, this.updateChildView);
|
|
||||||
},
|
|
||||||
|
|
||||||
render(buffer) {
|
|
||||||
const element = buffer.element();
|
|
||||||
const dom = buffer.dom;
|
|
||||||
|
|
||||||
this._childViewsMorph = dom.appendMorph(element);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateChildView() {
|
|
||||||
this._scheduled = false;
|
|
||||||
if (!this._elementCreated || this.isDestroying || this.isDestroyed) { return; }
|
|
||||||
|
|
||||||
const childView = this._containedView;
|
|
||||||
if (childView && !childView._elementCreated) {
|
|
||||||
this._renderer.renderTree(childView, this, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
Triggers the set up for rendering a view that is cloaked.
|
|
||||||
|
|
||||||
@method uncloak
|
|
||||||
*/
|
|
||||||
uncloak() {
|
|
||||||
const state = this._state || this.state;
|
|
||||||
if (state !== 'inDOM' && state !== 'preRender') { return; }
|
|
||||||
|
|
||||||
if (!this._containedView) {
|
|
||||||
const model = this.get('content');
|
|
||||||
const container = this.get('container');
|
|
||||||
|
|
||||||
let controller;
|
|
||||||
|
|
||||||
// Wire up the itemController if necessary
|
|
||||||
const controllerName = this.get('cloaksController');
|
|
||||||
if (controllerName) {
|
|
||||||
const controllerFullName = 'controller:' + controllerName;
|
|
||||||
let factory = container.lookupFactory(controllerFullName);
|
|
||||||
|
|
||||||
// let ember generate controller if needed
|
|
||||||
if (!factory) {
|
|
||||||
factory = Ember.generateControllerFactory(container, controllerName, model);
|
|
||||||
|
|
||||||
// inform developer about typo
|
|
||||||
Ember.Logger.warn('ember-cloaking: can\'t lookup controller by name "' + controllerFullName + '".');
|
|
||||||
Ember.Logger.warn('ember-cloaking: using ' + factory.toString() + '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentController = this.get('controller');
|
|
||||||
controller = factory.create({ model, parentController, target: parentController });
|
|
||||||
}
|
|
||||||
|
|
||||||
const createArgs = {};
|
|
||||||
const target = controller || model;
|
|
||||||
|
|
||||||
if (this.get('preservesContext')) {
|
|
||||||
createArgs.content = target;
|
|
||||||
} else {
|
|
||||||
createArgs.context = target;
|
|
||||||
}
|
|
||||||
if (controller) { createArgs.controller = controller; }
|
|
||||||
this.setProperties({ style: ''.htmlSafe(), loading: false });
|
|
||||||
|
|
||||||
const cloaks = target && (target instanceof Placeholder) ? target.viewName : this.get('cloaks');
|
|
||||||
this.setContainedView(this.createChildView(cloaks, createArgs));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
Removes the view from the DOM and tears down all observers.
|
|
||||||
|
|
||||||
@method cloak
|
|
||||||
*/
|
|
||||||
cloak() {
|
|
||||||
const self = this;
|
|
||||||
|
|
||||||
if (this._containedView && (this._state || this.state) === 'inDOM') {
|
|
||||||
const style = `height: ${this.$().height()}px;`.htmlSafe();
|
|
||||||
this.set('style', style);
|
|
||||||
this.$().prop('style', style);
|
|
||||||
|
|
||||||
|
|
||||||
// We need to remove the container after the height of the element has taken
|
|
||||||
// effect.
|
|
||||||
Ember.run.schedule('afterRender', function() {
|
|
||||||
self.setContainedView(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_setHeights: function(){
|
|
||||||
if (!this._containedView) {
|
|
||||||
// setting default height
|
|
||||||
// but do not touch if height already defined
|
|
||||||
if(!this.$().height()){
|
|
||||||
let defaultHeight = 100;
|
|
||||||
if(this.get('defaultHeight')) {
|
|
||||||
defaultHeight = this.get('defaultHeight');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$().css('height', defaultHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.on('didInsertElement')
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import ScreenTrack from 'discourse/lib/screen-track';
|
|
||||||
|
|
||||||
export default Discourse.GroupedView.extend({
|
|
||||||
templateName: 'embedded-post',
|
|
||||||
classNames: ['reply'],
|
|
||||||
attributeBindings: ['data-post-id'],
|
|
||||||
'data-post-id': Em.computed.alias('content.id'),
|
|
||||||
|
|
||||||
_startTracking: function() {
|
|
||||||
const post = this.get('content');
|
|
||||||
ScreenTrack.current().track(this.get('elementId'), post.get('post_number'));
|
|
||||||
}.on('didInsertElement'),
|
|
||||||
|
|
||||||
_stopTracking: function() {
|
|
||||||
ScreenTrack.current().stopTracking(this.get('elementId'));
|
|
||||||
}.on('willDestroyElement')
|
|
||||||
});
|
|
|
@ -1,10 +0,0 @@
|
||||||
export default Ember.View.extend({
|
|
||||||
_groupInit: function() {
|
|
||||||
this.set('context', this.get('content'));
|
|
||||||
|
|
||||||
const templateData = this.get('templateData');
|
|
||||||
if (templateData) {
|
|
||||||
this.set('templateData.insideGroup', true);
|
|
||||||
}
|
|
||||||
}.on('init')
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
export default Discourse.GroupedView.extend({
|
|
||||||
classNames: ['archetype-option'],
|
|
||||||
composerControllerBinding: 'Discourse.router.composerController',
|
|
||||||
templateName: "modal/option_boolean",
|
|
||||||
|
|
||||||
_checkedChanged: function() {
|
|
||||||
var metaData = this.get('parentView.metaData');
|
|
||||||
metaData.set(this.get('content.key'), this.get('checked') ? 'true' : 'false');
|
|
||||||
this.get('controller.controllers.composer').saveDraft();
|
|
||||||
}.observes('checked')
|
|
||||||
});
|
|
|
@ -1,381 +0,0 @@
|
||||||
import ScreenTrack from 'discourse/lib/screen-track';
|
|
||||||
import { number } from 'discourse/lib/formatter';
|
|
||||||
import DiscourseURL from 'discourse/lib/url';
|
|
||||||
import { default as computed, on } from 'ember-addons/ember-computed-decorators';
|
|
||||||
import { fmt } from 'discourse/lib/computed';
|
|
||||||
import { isValidLink } from 'discourse/lib/click-track';
|
|
||||||
|
|
||||||
const DAY = 60 * 50 * 1000;
|
|
||||||
|
|
||||||
const PostView = Discourse.GroupedView.extend(Ember.Evented, {
|
|
||||||
classNames: ['topic-post', 'clearfix'],
|
|
||||||
classNameBindings: ['needsModeratorClass:moderator:regular',
|
|
||||||
'selected',
|
|
||||||
'post.hidden:post-hidden',
|
|
||||||
'post.deleted:deleted',
|
|
||||||
'post.topicOwner:topic-owner',
|
|
||||||
'groupNameClass',
|
|
||||||
'post.wiki:wiki',
|
|
||||||
'whisper'],
|
|
||||||
|
|
||||||
post: Ember.computed.alias('content'),
|
|
||||||
postElementId: fmt('post.post_number', 'post_%@'),
|
|
||||||
likedUsers: null,
|
|
||||||
|
|
||||||
@on('init')
|
|
||||||
initLikedUsers() {
|
|
||||||
this.set('likedUsers', []);
|
|
||||||
},
|
|
||||||
|
|
||||||
@computed('post.post_type')
|
|
||||||
whisper(postType) {
|
|
||||||
return postType === this.site.get('post_types.whisper');
|
|
||||||
},
|
|
||||||
|
|
||||||
templateName: function() {
|
|
||||||
return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post';
|
|
||||||
}.property('post.post_type'),
|
|
||||||
|
|
||||||
historyHeat: function() {
|
|
||||||
const updatedAt = this.get('post.updated_at');
|
|
||||||
if (!updatedAt) { return; }
|
|
||||||
|
|
||||||
// Show heat on age
|
|
||||||
const rightNow = new Date().getTime(),
|
|
||||||
updatedAtDate = new Date(updatedAt).getTime();
|
|
||||||
|
|
||||||
if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_low)) return 'heatmap-high';
|
|
||||||
if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_medium)) return 'heatmap-med';
|
|
||||||
if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_high)) return 'heatmap-low';
|
|
||||||
}.property('post.updated_at'),
|
|
||||||
|
|
||||||
needsModeratorClass: function() {
|
|
||||||
return (this.get('post.post_type') === this.site.get('post_types.moderator_action')) ||
|
|
||||||
(this.get('post.topic.is_warning') && this.get('post.firstPost'));
|
|
||||||
}.property('post.post_type'),
|
|
||||||
|
|
||||||
groupNameClass: function() {
|
|
||||||
const primaryGroupName = this.get('post.primary_group_name');
|
|
||||||
if (primaryGroupName) {
|
|
||||||
return "group-" + primaryGroupName;
|
|
||||||
}
|
|
||||||
}.property('post.primary_group_name'),
|
|
||||||
|
|
||||||
showExpandButton: function() {
|
|
||||||
if (this.get('controller.firstPostExpanded')) { return false; }
|
|
||||||
|
|
||||||
const post = this.get('post');
|
|
||||||
return post.get('post_number') === 1 && post.get('topic.expandable_first_post');
|
|
||||||
}.property('post.post_number', 'controller.firstPostExpanded'),
|
|
||||||
|
|
||||||
// If the cooked content changed, add the quote controls
|
|
||||||
cookedChanged: function() {
|
|
||||||
Em.run.scheduleOnce('afterRender', this, '_cookedWasChanged');
|
|
||||||
}.observes('post.cooked'),
|
|
||||||
|
|
||||||
_cookedWasChanged() {
|
|
||||||
this.trigger('postViewUpdated', this.$());
|
|
||||||
this._insertQuoteControls();
|
|
||||||
},
|
|
||||||
|
|
||||||
mouseUp(e) {
|
|
||||||
if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) {
|
|
||||||
this.get('controller').toggledSelectedPost(this.get('post'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
selected: function() {
|
|
||||||
return this.get('controller').postSelected(this.get('post'));
|
|
||||||
}.property('controller.selectedPostsCount'),
|
|
||||||
|
|
||||||
canSelectReplies: function() {
|
|
||||||
if (this.get('post.reply_count') === 0) { return false; }
|
|
||||||
return !this.get('selected');
|
|
||||||
}.property('post.reply_count', 'selected'),
|
|
||||||
|
|
||||||
selectPostText: function() {
|
|
||||||
return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select');
|
|
||||||
}.property('selected', 'controller.selectedPostsCount'),
|
|
||||||
|
|
||||||
repliesShown: Em.computed.gt('post.replies.length', 0),
|
|
||||||
|
|
||||||
_updateQuoteElements($aside, desc) {
|
|
||||||
let navLink = "";
|
|
||||||
const quoteTitle = I18n.t("post.follow_quote"),
|
|
||||||
postNumber = $aside.data('post');
|
|
||||||
|
|
||||||
if (postNumber) {
|
|
||||||
|
|
||||||
// If we have a topic reference
|
|
||||||
let topicId, topic;
|
|
||||||
if (topicId = $aside.data('topic')) {
|
|
||||||
topic = this.get('controller.content');
|
|
||||||
|
|
||||||
// If it's the same topic as ours, build the URL from the topic object
|
|
||||||
if (topic && topic.get('id') === topicId) {
|
|
||||||
navLink = `<a href='${topic.urlForPostNumber(postNumber)}' title='${quoteTitle}' class='back'></a>`;
|
|
||||||
} else {
|
|
||||||
// Made up slug should be replaced with canonical URL
|
|
||||||
navLink = `<a href='${Discourse.getURL("/t/via-quote/") + topicId + "/" + postNumber}' title='${quoteTitle}' class='quote-other-topic'></a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (topic = this.get('controller.content')) {
|
|
||||||
// assume the same topic
|
|
||||||
navLink = `<a href='${topic.urlForPostNumber(postNumber)}' title='${quoteTitle}' class='back'></a>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only add the expand/contract control if it's not a full post
|
|
||||||
let expandContract = "";
|
|
||||||
if (!$aside.data('full')) {
|
|
||||||
expandContract = `<i class='fa fa-${desc}' title='${I18n.t("post.expand_collapse")}'></i>`;
|
|
||||||
$('.title', $aside).css('cursor', 'pointer');
|
|
||||||
}
|
|
||||||
$('.quote-controls', $aside).html(expandContract + navLink);
|
|
||||||
},
|
|
||||||
|
|
||||||
_toggleQuote($aside) {
|
|
||||||
if (this.get('expanding')) { return; }
|
|
||||||
|
|
||||||
this.set('expanding', true);
|
|
||||||
|
|
||||||
$aside.data('expanded', !$aside.data('expanded'));
|
|
||||||
|
|
||||||
const finished = () => this.set('expanding', false);
|
|
||||||
|
|
||||||
if ($aside.data('expanded')) {
|
|
||||||
this._updateQuoteElements($aside, 'chevron-up');
|
|
||||||
// Show expanded quote
|
|
||||||
const $blockQuote = $('blockquote', $aside);
|
|
||||||
$aside.data('original-contents', $blockQuote.html());
|
|
||||||
|
|
||||||
const originalText = $blockQuote.text().trim();
|
|
||||||
$blockQuote.html(I18n.t("loading"));
|
|
||||||
let topicId = this.get('post.topic_id');
|
|
||||||
if ($aside.data('topic')) {
|
|
||||||
topicId = $aside.data('topic');
|
|
||||||
}
|
|
||||||
|
|
||||||
const postId = parseInt($aside.data('post'), 10);
|
|
||||||
topicId = parseInt(topicId, 10);
|
|
||||||
|
|
||||||
Discourse.ajax(`/posts/by_number/${topicId}/${postId}`).then(result => {
|
|
||||||
const div = $("<div class='expanded-quote'></div>");
|
|
||||||
div.html(result.cooked);
|
|
||||||
div.highlight(originalText, {caseSensitive: true, element: 'span', className: 'highlighted'});
|
|
||||||
$blockQuote.showHtml(div, 'fast', finished);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Hide expanded quote
|
|
||||||
this._updateQuoteElements($aside, 'chevron-down');
|
|
||||||
$('blockquote', $aside).showHtml($aside.data('original-contents'), 'fast', finished);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Show how many times links have been clicked on
|
|
||||||
_showLinkCounts() {
|
|
||||||
const self = this,
|
|
||||||
link_counts = this.get('post.link_counts');
|
|
||||||
|
|
||||||
if (!link_counts) { return; }
|
|
||||||
|
|
||||||
link_counts.forEach(function(lc) {
|
|
||||||
if (!lc.clicks || lc.clicks < 1) { return; }
|
|
||||||
|
|
||||||
self.$(".cooked a[href]").each(function() {
|
|
||||||
const $link = $(this),
|
|
||||||
href = $link.attr('href');
|
|
||||||
|
|
||||||
let valid = !lc.internal && href === lc.url;
|
|
||||||
|
|
||||||
// this might be an attachment
|
|
||||||
if (lc.internal) { valid = href.indexOf(lc.url) >= 0; }
|
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
// don't display badge counts on category badge & oneboxes (unless when explicitely stated)
|
|
||||||
if (isValidLink($link)) {
|
|
||||||
$link.append("<span class='badge badge-notification clicks' title='" + I18n.t("topic_map.clicks", {count: lc.clicks}) + "'>" + number(lc.clicks) + "</span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
toggleLike() {
|
|
||||||
const currentUser = this.get('controller.currentUser');
|
|
||||||
const post = this.get('post');
|
|
||||||
const likeAction = post.get('likeAction');
|
|
||||||
if (likeAction && likeAction.get('canToggle')) {
|
|
||||||
const users = this.get('likedUsers');
|
|
||||||
const store = this.get('controller.store');
|
|
||||||
const action = store.createRecord('post-action-user',
|
|
||||||
currentUser.getProperties('id', 'username', 'avatar_template')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (likeAction.toggle(post) && users.get('length')) {
|
|
||||||
users.addObject(action);
|
|
||||||
} else {
|
|
||||||
users.removeObject(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleWhoLiked() {
|
|
||||||
const post = this.get('post');
|
|
||||||
const likeAction = post.get('likeAction');
|
|
||||||
if (likeAction) {
|
|
||||||
const users = this.get('likedUsers');
|
|
||||||
if (users.get('length')) {
|
|
||||||
users.clear();
|
|
||||||
} else {
|
|
||||||
likeAction.loadUsers(post).then(newUsers => this.set('likedUsers', newUsers));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Toggle the replies this post is a reply to
|
|
||||||
toggleReplyHistory(post) {
|
|
||||||
const replyHistory = post.get('replyHistory'),
|
|
||||||
topicController = this.get('controller'),
|
|
||||||
origScrollTop = $(window).scrollTop(),
|
|
||||||
replyPostNumber = this.get('post.reply_to_post_number'),
|
|
||||||
postNumber = this.get('post.post_number'),
|
|
||||||
self = this;
|
|
||||||
|
|
||||||
if (Discourse.Mobile.mobileView) {
|
|
||||||
DiscourseURL.routeTo(this.get('post.topic').urlForPostNumber(replyPostNumber));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = topicController.get('model.postStream');
|
|
||||||
const offsetFromTop = this.$().position().top - $(window).scrollTop();
|
|
||||||
|
|
||||||
if(Discourse.SiteSettings.experimental_reply_expansion) {
|
|
||||||
if(postNumber - replyPostNumber > 1) {
|
|
||||||
stream.collapsePosts(replyPostNumber + 1, postNumber - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Em.run.next(function() {
|
|
||||||
PostView.highlight(replyPostNumber);
|
|
||||||
$(window).scrollTop(self.$().position().top - offsetFromTop);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replyHistory.length > 0) {
|
|
||||||
const origHeight = this.$('.embedded-posts.top').height();
|
|
||||||
|
|
||||||
replyHistory.clear();
|
|
||||||
Em.run.next(function() {
|
|
||||||
$(window).scrollTop(origScrollTop - origHeight);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
post.set('loadingReplyHistory', true);
|
|
||||||
|
|
||||||
stream.findReplyHistory(post).then(function () {
|
|
||||||
post.set('loadingReplyHistory', false);
|
|
||||||
|
|
||||||
Em.run.next(function() {
|
|
||||||
$(window).scrollTop(origScrollTop + self.$('.embedded-posts.top').height());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add the quote controls to a post
|
|
||||||
_insertQuoteControls() {
|
|
||||||
const self = this,
|
|
||||||
$quotes = this.$('aside.quote');
|
|
||||||
|
|
||||||
// Safety check - in some cases with cloackedView this seems to be `undefined`.
|
|
||||||
if (Em.isEmpty($quotes)) { return; }
|
|
||||||
|
|
||||||
$quotes.each(function(i, e) {
|
|
||||||
const $aside = $(e);
|
|
||||||
if ($aside.data('post')) {
|
|
||||||
self._updateQuoteElements($aside, 'chevron-down');
|
|
||||||
const $title = $('.title', $aside);
|
|
||||||
|
|
||||||
// Unless it's a full quote, allow click to expand
|
|
||||||
if (!($aside.data('full') || $title.data('has-quote-controls'))) {
|
|
||||||
$title.on('click', function(e2) {
|
|
||||||
if ($(e2.target).is('a')) return true;
|
|
||||||
self._toggleQuote($aside);
|
|
||||||
});
|
|
||||||
$title.data('has-quote-controls', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_destroyedPostView: function() {
|
|
||||||
ScreenTrack.current().stopTracking(this.get('elementId'));
|
|
||||||
}.on('willDestroyElement'),
|
|
||||||
|
|
||||||
_postViewInserted: function() {
|
|
||||||
const $post = this.$(),
|
|
||||||
postNumber = this.get('post').get('post_number');
|
|
||||||
|
|
||||||
this._showLinkCounts();
|
|
||||||
|
|
||||||
ScreenTrack.current().track($post.prop('id'), postNumber);
|
|
||||||
|
|
||||||
this.trigger('postViewInserted', $post);
|
|
||||||
|
|
||||||
// Find all the quotes
|
|
||||||
Em.run.scheduleOnce('afterRender', this, '_insertQuoteControls');
|
|
||||||
|
|
||||||
$post.closest('.post-cloak').attr('data-post-number', postNumber);
|
|
||||||
this._applySearchHighlight();
|
|
||||||
}.on('didInsertElement'),
|
|
||||||
|
|
||||||
_fixImageSizes: function(){
|
|
||||||
var maxWidth;
|
|
||||||
this.$('img:not(.avatar)').each(function(idx,img){
|
|
||||||
|
|
||||||
// deferring work only for posts with images
|
|
||||||
// we got to use screen here, cause nothing is rendered yet.
|
|
||||||
// long term we may want to allow for weird margins that are enforced, instead of hardcoding at 70/20
|
|
||||||
maxWidth = maxWidth || $(window).width() - (Discourse.Mobile.mobileView ? 20 : 70);
|
|
||||||
if (Discourse.SiteSettings.max_image_width < maxWidth) {
|
|
||||||
maxWidth = Discourse.SiteSettings.max_image_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
var aspect = img.height / img.width;
|
|
||||||
if (img.width > maxWidth) {
|
|
||||||
img.width = maxWidth;
|
|
||||||
img.height = parseInt(maxWidth * aspect,10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// very unlikely but lets fix this too
|
|
||||||
if (img.height > Discourse.SiteSettings.max_image_height) {
|
|
||||||
img.height = Discourse.SiteSettings.max_image_height;
|
|
||||||
img.width = parseInt(maxWidth / aspect,10);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}.on('willInsertElement'),
|
|
||||||
|
|
||||||
_applySearchHighlight: function() {
|
|
||||||
const highlight = this.get('searchService.highlightTerm');
|
|
||||||
const cooked = this.$('.cooked');
|
|
||||||
|
|
||||||
if (!cooked) { return; }
|
|
||||||
|
|
||||||
if (highlight && highlight.length > 2) {
|
|
||||||
if (this._highlighted) {
|
|
||||||
cooked.unhighlight();
|
|
||||||
}
|
|
||||||
cooked.highlight(highlight.split(/\s+/));
|
|
||||||
this._highlighted = true;
|
|
||||||
|
|
||||||
} else if (this._highlighted) {
|
|
||||||
cooked.unhighlight();
|
|
||||||
this._highlighted = false;
|
|
||||||
}
|
|
||||||
}.observes('searchService.highlightTerm', 'cooked')
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PostView;
|
|
|
@ -1,7 +0,0 @@
|
||||||
export default Em.CollectionView.extend({
|
|
||||||
tagName: 'section',
|
|
||||||
classNameBindings: [':embedded-posts', ':top', ':topic-body', ':offset2', 'hidden'],
|
|
||||||
itemViewClass: 'embedded-post',
|
|
||||||
hidden: Em.computed.equal('content.length', 0),
|
|
||||||
previousPost: true
|
|
||||||
});
|
|
|
@ -1,46 +0,0 @@
|
||||||
import ContainerView from 'discourse/views/container';
|
|
||||||
import { default as computed, observes, on } from 'ember-addons/ember-computed-decorators';
|
|
||||||
|
|
||||||
export default ContainerView.extend({
|
|
||||||
classNameBindings: ['hidden', ':topic-map'],
|
|
||||||
|
|
||||||
@observes('topic.posts_count')
|
|
||||||
_postsChanged() {
|
|
||||||
Ember.run.once(this, 'rerender');
|
|
||||||
},
|
|
||||||
|
|
||||||
@computed
|
|
||||||
hidden() {
|
|
||||||
if (!this.get('post.firstPost')) return true;
|
|
||||||
|
|
||||||
const topic = this.get('topic');
|
|
||||||
if (topic.get('archetype') === 'private_message') return false;
|
|
||||||
if (topic.get('archetype') !== 'regular') return true;
|
|
||||||
return topic.get('posts_count') < 2;
|
|
||||||
},
|
|
||||||
|
|
||||||
@on('init')
|
|
||||||
startAppending() {
|
|
||||||
if (this.get('hidden')) return;
|
|
||||||
|
|
||||||
this.attachViewWithArgs({ topic: this.get('topic') }, 'topic-map');
|
|
||||||
this.trigger('appendMapInformation', this);
|
|
||||||
},
|
|
||||||
|
|
||||||
appendMapInformation(view) {
|
|
||||||
const topic = this.get('topic');
|
|
||||||
|
|
||||||
if (topic.get('has_summary')) {
|
|
||||||
view.attachViewWithArgs({ topic, filterBinding: 'controller.filter' }, 'toggle-summary');
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = this.get('controller.currentUser');
|
|
||||||
if (currentUser && currentUser.get('staff') && topic.get('has_deleted')) {
|
|
||||||
view.attachViewWithArgs({ topic, filterBinding: 'controller.filter' }, 'topic-deleted');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.get('topic.isPrivateMessage')) {
|
|
||||||
view.attachViewWithArgs({ topic, showPrivateInviteAction: 'showInvite' }, 'private-message-map');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
136
app/assets/javascripts/discourse/widgets/actions-summary.js.es6
Normal file
136
app/assets/javascripts/discourse/widgets/actions-summary.js.es6
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { avatarFor } from 'discourse/widgets/post';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { dateNode } from 'discourse/helpers/node';
|
||||||
|
|
||||||
|
export function avatarAtts(user) {
|
||||||
|
return { template: user.avatar_template,
|
||||||
|
username: user.username,
|
||||||
|
post_url: user.post_url,
|
||||||
|
url: Discourse.getURL('/users/') + user.username_lower };
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget('small-user-list', {
|
||||||
|
tagName: 'div.clearfix',
|
||||||
|
|
||||||
|
buildClasses(atts) {
|
||||||
|
return atts.listClassName;
|
||||||
|
},
|
||||||
|
|
||||||
|
html(atts) {
|
||||||
|
let users = atts.users;
|
||||||
|
if (users) {
|
||||||
|
const currentUser = this.currentUser;
|
||||||
|
if (atts.addSelf && !users.some(u => u.username === currentUser.username)) {
|
||||||
|
users = users.concat(avatarAtts(currentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = I18n.t(atts.description, { icons: '' });
|
||||||
|
|
||||||
|
// oddly post_url is on the user
|
||||||
|
let postUrl;
|
||||||
|
const icons = users.map(u => {
|
||||||
|
postUrl = postUrl || u.post_url;
|
||||||
|
return avatarFor.call(this, 'small', u);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (postUrl) {
|
||||||
|
description = h('a', { attributes: { href: Discourse.getURL(postUrl) } }, description);
|
||||||
|
}
|
||||||
|
return [icons, description, '.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('action-link', {
|
||||||
|
tagName: 'span.action-link',
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
return attrs.className;
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
return h('a', [attrs.text, '. ']);
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction(this.attrs.action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('actions-summary-item', {
|
||||||
|
tagName: 'div.post-action',
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { users: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const users = state.users;
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const action = attrs.action;
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
result.push(this.attach('action-link', { action: 'whoActed', text: attrs.description }));
|
||||||
|
} else {
|
||||||
|
result.push(this.attach('small-user-list', { users, description: `post.actions.people.${action}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.canUndo) {
|
||||||
|
result.push(this.attach('action-link', { action: 'undo', className: 'undo', text: I18n.t(`post.actions.undo.${action}`)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.canDeferFlags) {
|
||||||
|
const flagsDesc = I18n.t(`post.actions.defer_flags`, { count: attrs.count });
|
||||||
|
result.push(this.attach('action-link', { action: 'deferFlags', className: 'defer-flags', text: flagsDesc }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
whoActed() {
|
||||||
|
const attrs = this.attrs;
|
||||||
|
const state = this.state;
|
||||||
|
return this.store.find('post-action-user', { id: attrs.postId, post_action_type_id: attrs.id }).then(users => {
|
||||||
|
state.users = users.map(avatarAtts);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.sendWidgetAction('undoPostAction', this.attrs.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
deferFlags() {
|
||||||
|
this.sendWidgetAction('deferPostActionFlags', this.attrs.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('actions-summary', {
|
||||||
|
tagName: 'section.post-actions',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const actionsSummary = attrs.actionsSummary || [];
|
||||||
|
const body = [];
|
||||||
|
actionsSummary.forEach(as => {
|
||||||
|
body.push(this.attach('actions-summary-item', as));
|
||||||
|
body.push(h('div.clearfix'));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attrs.isDeleted) {
|
||||||
|
body.push(h('div.post-action', [
|
||||||
|
iconNode('trash-o'),
|
||||||
|
' ',
|
||||||
|
avatarFor.call(this, 'small', {
|
||||||
|
template: attrs.deletedByAvatarTemplate,
|
||||||
|
username: attrs.deletedByUsername
|
||||||
|
}),
|
||||||
|
' ',
|
||||||
|
dateNode(attrs.deleted_at)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
});
|
48
app/assets/javascripts/discourse/widgets/button.js.es6
Normal file
48
app/assets/javascripts/discourse/widgets/button.js.es6
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
|
||||||
|
export default createWidget('button', {
|
||||||
|
tagName: 'button',
|
||||||
|
|
||||||
|
buildClasses() {
|
||||||
|
if (this.attrs.className) { return this.attrs.className; }
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAttributes() {
|
||||||
|
const attrs = this.attrs;
|
||||||
|
|
||||||
|
let title;
|
||||||
|
if (attrs.title) {
|
||||||
|
title = I18n.t(attrs.title, attrs.titleOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = { "aria-label": title, title };
|
||||||
|
if (attrs.disabled) { attributes.disabled = "true"; }
|
||||||
|
|
||||||
|
if (attrs.data) {
|
||||||
|
Object.keys(attrs.data).forEach(k => attributes[`data-${k}`] = attrs.data[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const contents = [];
|
||||||
|
|
||||||
|
const left = !attrs.iconRight;
|
||||||
|
if (attrs.icon && left) { contents.push(iconNode(attrs.icon)); }
|
||||||
|
if (attrs.label) { contents.push(I18n.t(attrs.label, attrs.labelOptions)); }
|
||||||
|
if (attrs.contents) { contents.push(attrs.contents); }
|
||||||
|
if (attrs.icon && !left) { contents.push(iconNode(attrs.icon)); }
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
const attrs = this.attrs;
|
||||||
|
if (attrs.disabled) { return; }
|
||||||
|
|
||||||
|
$(`button`).blur();
|
||||||
|
return this.sendWidgetAction(attrs.action);
|
||||||
|
}
|
||||||
|
});
|
61
app/assets/javascripts/discourse/widgets/click-hook.js.es6
Normal file
61
app/assets/javascripts/discourse/widgets/click-hook.js.es6
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
const CLICK_ATTRIBUTE_NAME = '_discourse_click_widget';
|
||||||
|
const CLICK_OUTSIDE_ATTRIBUTE_NAME = '_discourse_click_outside_widget';
|
||||||
|
|
||||||
|
export class WidgetClickHook {
|
||||||
|
constructor(widget) {
|
||||||
|
this.widget = widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
hook(node) {
|
||||||
|
node[CLICK_ATTRIBUTE_NAME] = this.widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
unhook(node) {
|
||||||
|
node[CLICK_ATTRIBUTE_NAME] = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WidgetClickOutsideHook {
|
||||||
|
constructor(widget) {
|
||||||
|
this.widget = widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
hook(node) {
|
||||||
|
node.setAttribute('data-click-outside', true);
|
||||||
|
node[CLICK_OUTSIDE_ATTRIBUTE_NAME] = this.widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
unhook(node) {
|
||||||
|
node.removeAttribute('data-click-outside');
|
||||||
|
node[CLICK_OUTSIDE_ATTRIBUTE_NAME] = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _watchingDocument = false;
|
||||||
|
WidgetClickHook.setupDocumentCallback = function() {
|
||||||
|
if (_watchingDocument) { return; }
|
||||||
|
|
||||||
|
$(document).on('click.discourse-widget', e => {
|
||||||
|
let node = e.target;
|
||||||
|
while (node) {
|
||||||
|
const widget = node[CLICK_ATTRIBUTE_NAME];
|
||||||
|
if (widget) {
|
||||||
|
return widget.click(e);
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
node = e.target;
|
||||||
|
const $outside = $('[data-click-outside]');
|
||||||
|
$outside.each((i, outNode) => {
|
||||||
|
if (outNode.contains(node)) { return; }
|
||||||
|
const widget = outNode[CLICK_OUTSIDE_ATTRIBUTE_NAME];
|
||||||
|
if (widget) {
|
||||||
|
return widget.clickOutside(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
_watchingDocument = true;
|
||||||
|
};
|
|
@ -0,0 +1,42 @@
|
||||||
|
import RawHtml from 'discourse/widgets/raw-html';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import DiscourseURL from 'discourse/lib/url';
|
||||||
|
|
||||||
|
createWidget('post-link-arrow', {
|
||||||
|
html(attrs) {
|
||||||
|
if (attrs.above) {
|
||||||
|
return h('a.post-info.arrow', {
|
||||||
|
attributes: { title: I18n.t('topic.jump_reply_up') }
|
||||||
|
}, iconNode('arrow-up'));
|
||||||
|
} else {
|
||||||
|
return h('a.post-info.arrow', {
|
||||||
|
attributes: { title: I18n.t('topic.jump_reply_down') }
|
||||||
|
}, iconNode('arrow-down'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
DiscourseURL.jumpToPost(this.attrs.post_number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('embedded-post', {
|
||||||
|
buildKey: attrs => `embedded-post-${attrs.id}`,
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
return [
|
||||||
|
h('div.row', [
|
||||||
|
this.attach('post-avatar', attrs),
|
||||||
|
h('div.topic-body', [
|
||||||
|
h('div.topic-meta-data', [
|
||||||
|
this.attach('poster-name', attrs),
|
||||||
|
this.attach('post-link-arrow', { above: state.above, post_number: attrs.post_number })
|
||||||
|
]),
|
||||||
|
new RawHtml({html: `<div class='cooked'>${attrs.cooked}</div>`})
|
||||||
|
])
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
createWidget('post-admin-menu-button', {
|
||||||
|
tagName: 'li.btn',
|
||||||
|
buildClasses(attrs) {
|
||||||
|
return attrs.className;
|
||||||
|
},
|
||||||
|
html(attrs) {
|
||||||
|
return [iconNode(attrs.icon), I18n.t(attrs.label)];
|
||||||
|
},
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction('closeAdminMenu');
|
||||||
|
return this.sendWidgetAction(this.attrs.action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('post-admin-menu', {
|
||||||
|
tagName: 'div.post-admin-menu.popup-menu',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const contents = [];
|
||||||
|
contents.push(h('h3', I18n.t('admin_title')));
|
||||||
|
|
||||||
|
if (this.currentUser.staff) {
|
||||||
|
const buttonAtts = { action: 'togglePostType', icon: 'shield', className: 'toggle-post-type' };
|
||||||
|
|
||||||
|
if (attrs.isModeratorAction) {
|
||||||
|
buttonAtts.label = 'post.controls.revert_to_regular';
|
||||||
|
} else {
|
||||||
|
buttonAtts.label = 'post.controls.convert_to_moderator';
|
||||||
|
}
|
||||||
|
contents.push(this.attach('post-admin-menu-button', buttonAtts));
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.push(this.attach('post-admin-menu-button', {
|
||||||
|
icon: 'cog', label: 'post.controls.rebake', action: 'rebakePost', className: 'rebuild-html'
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (attrs.hidden) {
|
||||||
|
contents.push(this.attach('post-admin-menu-button', {
|
||||||
|
icon: 'eye',
|
||||||
|
label: 'post.controls.unhide',
|
||||||
|
action: 'unhidePost',
|
||||||
|
className: 'unhide-post'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentUser.admin) {
|
||||||
|
contents.push(this.attach('post-admin-menu-button', {
|
||||||
|
icon: 'user',
|
||||||
|
label: 'post.controls.change_owner',
|
||||||
|
action: 'changePostOwner',
|
||||||
|
className: 'change-owner'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
},
|
||||||
|
|
||||||
|
clickOutside() {
|
||||||
|
this.sendWidgetAction('closeAdminMenu');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
196
app/assets/javascripts/discourse/widgets/post-cooked.js.es6
Normal file
196
app/assets/javascripts/discourse/widgets/post-cooked.js.es6
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
import { isValidLink } from 'discourse/lib/click-track';
|
||||||
|
import { number } from 'discourse/lib/formatter';
|
||||||
|
|
||||||
|
export default class PostCooked {
|
||||||
|
|
||||||
|
constructor(attrs) {
|
||||||
|
this.attrs = attrs;
|
||||||
|
this.expanding = false;
|
||||||
|
this._highlighted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(prev) {
|
||||||
|
if (prev.attrs.cooked !== this.attrs.cooked) {
|
||||||
|
return this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const $html = $(`<div class='cooked'>${this.attrs.cooked}</div>`);
|
||||||
|
this._insertQuoteControls($html);
|
||||||
|
this._showLinkCounts($html);
|
||||||
|
this._fixImageSizes($html);
|
||||||
|
this._applySearchHighlight($html);
|
||||||
|
return $html[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
_applySearchHighlight($html) {
|
||||||
|
const highlight = this.attrs.highlightTerm;
|
||||||
|
|
||||||
|
if (highlight && highlight.length > 2) {
|
||||||
|
if (this._highlighted) {
|
||||||
|
$html.unhighlight();
|
||||||
|
}
|
||||||
|
$html.highlight(highlight.split(/\s+/));
|
||||||
|
this._highlighted = true;
|
||||||
|
|
||||||
|
} else if (this._highlighted) {
|
||||||
|
$html.unhighlight();
|
||||||
|
this._highlighted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_fixImageSizes($html) {
|
||||||
|
const maxImageWidth = Discourse.SiteSettings.max_image_width;
|
||||||
|
const maxImageHeight = Discourse.SiteSettings.max_image_height;
|
||||||
|
|
||||||
|
let maxWindowWidth;
|
||||||
|
$html.find('img:not(.avatar)').each((idx,img) => {
|
||||||
|
// deferring work only for posts with images
|
||||||
|
// we got to use screen here, cause nothing is rendered yet.
|
||||||
|
// long term we may want to allow for weird margins that are enforced, instead of hardcoding at 70/20
|
||||||
|
maxWindowWidth = maxWindowWidth || $(window).width() - (Discourse.Mobile.mobileView ? 20 : 70);
|
||||||
|
if (maxImageWidth < maxWindowWidth) {
|
||||||
|
maxWindowWidth = maxImageWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspect = img.height / img.width;
|
||||||
|
if (img.width > maxWindowWidth) {
|
||||||
|
img.width = maxWindowWidth;
|
||||||
|
img.height = parseInt(maxWindowWidth * aspect,10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// very unlikely but lets fix this too
|
||||||
|
if (img.height > maxImageHeight) {
|
||||||
|
img.height = maxImageHeight;
|
||||||
|
img.width = parseInt(maxWindowWidth / aspect,10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_showLinkCounts($html) {
|
||||||
|
const linkCounts = this.attrs.linkCounts;
|
||||||
|
if (!linkCounts) { return; }
|
||||||
|
|
||||||
|
linkCounts.forEach(lc => {
|
||||||
|
if (!lc.clicks || lc.clicks < 1) { return; }
|
||||||
|
|
||||||
|
$html.find('a[href]').each((i, e) => {
|
||||||
|
const $link = $(e);
|
||||||
|
const href = $link.attr('href');
|
||||||
|
|
||||||
|
let valid = !lc.internal && href === lc.url;
|
||||||
|
|
||||||
|
// this might be an attachment
|
||||||
|
if (lc.internal) { valid = href.indexOf(lc.url) >= 0; }
|
||||||
|
|
||||||
|
// don't display badge counts on category badge & oneboxes (unless when explicitely stated)
|
||||||
|
if (valid && isValidLink($link)) {
|
||||||
|
const title = I18n.t("topic_map.clicks", {count: lc.clicks});
|
||||||
|
$link.append(`<span class='badge badge-notification clicks' title='${title}'>${number(lc.clicks)}</span>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleQuote($aside) {
|
||||||
|
if (this.expanding) { return; }
|
||||||
|
|
||||||
|
this.expanding = true;
|
||||||
|
|
||||||
|
$aside.data('expanded', !$aside.data('expanded'));
|
||||||
|
|
||||||
|
const finished = () => this.expanding = false;
|
||||||
|
|
||||||
|
if ($aside.data('expanded')) {
|
||||||
|
this._updateQuoteElements($aside, 'chevron-up');
|
||||||
|
// Show expanded quote
|
||||||
|
const $blockQuote = $('blockquote', $aside);
|
||||||
|
$aside.data('original-contents', $blockQuote.html());
|
||||||
|
|
||||||
|
const originalText = $blockQuote.text().trim();
|
||||||
|
$blockQuote.html(I18n.t("loading"));
|
||||||
|
let topicId = this.attrs.topicId;
|
||||||
|
if ($aside.data('topic')) {
|
||||||
|
topicId = $aside.data('topic');
|
||||||
|
}
|
||||||
|
|
||||||
|
const postId = parseInt($aside.data('post'), 10);
|
||||||
|
topicId = parseInt(topicId, 10);
|
||||||
|
|
||||||
|
Discourse.ajax(`/posts/by_number/${topicId}/${postId}`).then(result => {
|
||||||
|
const div = $("<div class='expanded-quote'></div>");
|
||||||
|
div.html(result.cooked);
|
||||||
|
div.highlight(originalText, {caseSensitive: true, element: 'span', className: 'highlighted'});
|
||||||
|
$blockQuote.showHtml(div, 'fast', finished);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Hide expanded quote
|
||||||
|
this._updateQuoteElements($aside, 'chevron-down');
|
||||||
|
$('blockquote', $aside).showHtml($aside.data('original-contents'), 'fast', finished);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_urlForPostNumber(postNumber) {
|
||||||
|
return (postNumber > 0) ? `${this.topicUrl}/${postNumber}` : this.topicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateQuoteElements($aside, desc) {
|
||||||
|
let navLink = "";
|
||||||
|
const quoteTitle = I18n.t("post.follow_quote");
|
||||||
|
const postNumber = $aside.data('post');
|
||||||
|
|
||||||
|
if (postNumber) {
|
||||||
|
|
||||||
|
// If we have a topic reference
|
||||||
|
const asideTopicId = parseInt($aside.data('topic'));
|
||||||
|
|
||||||
|
if (asideTopicId) {
|
||||||
|
// If it's the same topic as ours, build the URL from the topic object
|
||||||
|
if (this.topicId === asideTopicId) {
|
||||||
|
navLink = `<a href='${this._urlForPostNumber(postNumber)}' title='${quoteTitle}' class='back'></a>`;
|
||||||
|
} else {
|
||||||
|
// Made up slug should be replaced with canonical URL
|
||||||
|
const asideLink = Discourse.getURL("/t/via-quote/") + asideTopicId + "/" + postNumber;
|
||||||
|
navLink = `<a href='${asideLink}' title='${quoteTitle}' class='quote-other-topic'></a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// assume the same topic
|
||||||
|
navLink = `<a href='${this._urlForPostNumber(postNumber)}' title='${quoteTitle}' class='back'></a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only add the expand/contract control if it's not a full post
|
||||||
|
let expandContract = "";
|
||||||
|
if (!$aside.data('full')) {
|
||||||
|
expandContract = `<i class='fa fa-${desc}' title='${I18n.t("post.expand_collapse")}'></i>`;
|
||||||
|
$('.title', $aside).css('cursor', 'pointer');
|
||||||
|
}
|
||||||
|
$('.quote-controls', $aside).html(expandContract + navLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
_insertQuoteControls($html) {
|
||||||
|
const $quotes = $html.find('aside.quote');
|
||||||
|
if ($quotes.length === 0) { return; }
|
||||||
|
|
||||||
|
$quotes.each((i, e) => {
|
||||||
|
const $aside = $(e);
|
||||||
|
if ($aside.data('post')) {
|
||||||
|
this._updateQuoteElements($aside, 'chevron-down');
|
||||||
|
const $title = $('.title', $aside);
|
||||||
|
|
||||||
|
// Unless it's a full quote, allow click to expand
|
||||||
|
if (!($aside.data('full') || $title.data('has-quote-controls'))) {
|
||||||
|
$title.on('click', e2 => {
|
||||||
|
if ($(e2.target).is('a')) return true;
|
||||||
|
this._toggleQuote($aside);
|
||||||
|
});
|
||||||
|
$title.data('has-quote-controls', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostCooked.prototype.type = 'Widget';
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { longDate } from 'discourse/lib/formatter';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
const FIFTY_HOURS = 60 * 50 * 1000;
|
||||||
|
|
||||||
|
export default createWidget('post-edits-indicator', {
|
||||||
|
tagName: 'div.post-info.edits',
|
||||||
|
|
||||||
|
historyHeat(updatedAt) {
|
||||||
|
if (!updatedAt) { return; }
|
||||||
|
|
||||||
|
// Show heat on age
|
||||||
|
const rightNow = new Date().getTime();
|
||||||
|
const updatedAtTime = updatedAt.getTime();
|
||||||
|
|
||||||
|
const siteSettings = this.siteSettings;
|
||||||
|
if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_low)) return 'heatmap-high';
|
||||||
|
if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_medium)) return 'heatmap-med';
|
||||||
|
if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_high)) return 'heatmap-low';
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const contents = [attrs.version - 1, ' ', iconNode('pencil')];
|
||||||
|
const updatedAt = new Date(attrs.updated_at);
|
||||||
|
|
||||||
|
const title = `${I18n.t('post.last_edited_on')} ${longDate(updatedAt)}`;
|
||||||
|
return h('a', {
|
||||||
|
className: this.historyHeat(updatedAt),
|
||||||
|
attributes: { title }
|
||||||
|
}, contents);
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
if (this.attrs.canViewEditHistory) {
|
||||||
|
this.sendWidgetAction('showHistory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
26
app/assets/javascripts/discourse/widgets/post-gap.js.es6
Normal file
26
app/assets/javascripts/discourse/widgets/post-gap.js.es6
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
|
||||||
|
export default createWidget('post-gap', {
|
||||||
|
tagName: 'div.gap.jagged-border',
|
||||||
|
buildKey: (attrs) => `post-gap-${attrs.pos}-${attrs.postId}`,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { loading: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
return state.loading ? I18n.t('loading') : I18n.t('post.gap', {count: attrs.gap.length});
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
const { attrs, state } = this;
|
||||||
|
|
||||||
|
if (state.loading) { return; }
|
||||||
|
state.loading = true;
|
||||||
|
|
||||||
|
this.scheduleRerender();
|
||||||
|
|
||||||
|
const args = { gap: attrs.gap, post: this.model };
|
||||||
|
return this.sendWidgetAction(attrs.pos === 'before' ? 'fillGapBefore' : 'fillGapAfter', args);
|
||||||
|
}
|
||||||
|
});
|
68
app/assets/javascripts/discourse/widgets/post-gutter.js.es6
Normal file
68
app/assets/javascripts/discourse/widgets/post-gutter.js.es6
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
const MAX_GUTTER_LINKS = 5;
|
||||||
|
|
||||||
|
export default createWidget('post-gutter', {
|
||||||
|
tagName: 'div.gutter',
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { collapsed: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const links = this.attrs.links || [];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
let toShow = links.length;
|
||||||
|
if (state.collapsed && toShow > MAX_GUTTER_LINKS) { toShow = MAX_GUTTER_LINKS; }
|
||||||
|
|
||||||
|
const seenTitles = {};
|
||||||
|
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < links.length && result.length < toShow) {
|
||||||
|
const l = links[i++];
|
||||||
|
|
||||||
|
let title = l.title;
|
||||||
|
if (title && !seenTitles[title]) {
|
||||||
|
seenTitles[title] = true;
|
||||||
|
const linkBody = [Discourse.Emoji.unescape(title)];
|
||||||
|
if (l.clicks) {
|
||||||
|
linkBody.push(h('span.badge.badge-notification.clicks', l.clicks.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = h('a.track-link', { attributes: { href: l.url } }, linkBody);
|
||||||
|
result.push(h('li', link));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.collapsed) {
|
||||||
|
const remaining = links.length - MAX_GUTTER_LINKS;
|
||||||
|
if (remaining > 0) {
|
||||||
|
result.push(h('li', h('a.toggle-more', I18n.t('post.more_links', {count: remaining}))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.canReplyAsNewTopic) {
|
||||||
|
result.push(h('a.reply-new', [iconNode('plus'), I18n.t('post.reply_as_new_topic')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('ul.post-links', result);
|
||||||
|
},
|
||||||
|
|
||||||
|
click(e) {
|
||||||
|
const $target = $(e.target);
|
||||||
|
if ($target.hasClass('toggle-more')) {
|
||||||
|
this.sendWidgetAction('showAll');
|
||||||
|
} else if ($target.closest('.reply-new').length) {
|
||||||
|
this.sendWidgetAction('newTopicAction');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
showAll() {
|
||||||
|
this.state.collapsed = false;
|
||||||
|
}
|
||||||
|
});
|
306
app/assets/javascripts/discourse/widgets/post-menu.js.es6
Normal file
306
app/assets/javascripts/discourse/widgets/post-menu.js.es6
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { avatarAtts } from 'discourse/widgets/actions-summary';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
const LIKE_ACTION = 2;
|
||||||
|
|
||||||
|
function animateHeart($elem, start, end, complete) {
|
||||||
|
if (Ember.testing) { return Ember.run(this, complete); }
|
||||||
|
|
||||||
|
$elem.stop()
|
||||||
|
.css('textIndent', start)
|
||||||
|
.animate({ textIndent: end }, {
|
||||||
|
complete,
|
||||||
|
step(now) {
|
||||||
|
$(this).css('transform','scale('+now+')');
|
||||||
|
},
|
||||||
|
duration: 150
|
||||||
|
}, 'linear');
|
||||||
|
}
|
||||||
|
|
||||||
|
const _builders = {};
|
||||||
|
|
||||||
|
function registerButton(name, builder) {
|
||||||
|
_builders[name] = builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerButton('like', attrs => {
|
||||||
|
if (!attrs.showLike) { return; }
|
||||||
|
const className = attrs.liked ? 'has-like fade-out' : 'like';
|
||||||
|
|
||||||
|
if (attrs.canToggleLike) {
|
||||||
|
const descKey = attrs.liked ? 'post.controls.undo_like' : 'post.controls.like';
|
||||||
|
return { action: 'like', title: descKey, icon: 'heart', className };
|
||||||
|
} else if (attrs.liked) {
|
||||||
|
return { action: 'like', title: 'post.controls.has_liked', icon: 'heart', className, disabled: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('like-count', attrs => {
|
||||||
|
const count = attrs.likeCount;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
const title = attrs.liked
|
||||||
|
? count === 1 ? 'post.has_likes_title_only_you' : 'post.has_likes_title_you'
|
||||||
|
: 'post.has_likes_title';
|
||||||
|
|
||||||
|
return { action: 'toggleWhoLiked',
|
||||||
|
title,
|
||||||
|
className: 'like-count highlight-action',
|
||||||
|
contents: I18n.t("post.has_likes", { count }),
|
||||||
|
titleOptions: {count: attrs.liked ? (count-1) : count }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('flag', attrs => {
|
||||||
|
if (attrs.canFlag) {
|
||||||
|
return { action: 'showFlags',
|
||||||
|
title: 'post.controls.flag',
|
||||||
|
icon: 'flag',
|
||||||
|
className: 'create-flag' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('edit', attrs => {
|
||||||
|
if (attrs.canEdit) {
|
||||||
|
return {
|
||||||
|
action: 'editPost',
|
||||||
|
className: 'edit',
|
||||||
|
title: 'post.controls.edit',
|
||||||
|
icon: 'pencil',
|
||||||
|
alwaysShowYours: true,
|
||||||
|
alwaysShowWiki: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('replies', (attrs, state, siteSettings) => {
|
||||||
|
const replyCount = attrs.replyCount;
|
||||||
|
|
||||||
|
if (!replyCount) { return; }
|
||||||
|
|
||||||
|
// Omit replies if the setting `suppress_reply_directly_below` is enabled
|
||||||
|
if (replyCount === 1 &&
|
||||||
|
attrs.replyDirectlyBelow &&
|
||||||
|
siteSettings.suppress_reply_directly_below) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'toggleRepliesBelow',
|
||||||
|
className: 'show-replies',
|
||||||
|
icon: state.repliesShown ? 'chevron-up' : 'chevron-down',
|
||||||
|
titleOptions: { count: replyCount },
|
||||||
|
title: 'post.has_replies',
|
||||||
|
labelOptions: { count: replyCount },
|
||||||
|
label: 'post.has_replies',
|
||||||
|
iconRight: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
registerButton('share', attrs => {
|
||||||
|
return {
|
||||||
|
action: 'share',
|
||||||
|
title: 'post.controls.share',
|
||||||
|
icon: 'link',
|
||||||
|
data: {
|
||||||
|
'share-url': attrs.shareUrl,
|
||||||
|
'post-number': attrs.post_number
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('reply', attrs => {
|
||||||
|
const args = {
|
||||||
|
action: 'replyToPost',
|
||||||
|
title: 'post.controls.reply',
|
||||||
|
icon: 'reply',
|
||||||
|
className: 'reply create fade-out'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!attrs.canCreatePost) { return; }
|
||||||
|
|
||||||
|
if (!Discourse.Mobile.mobileView) {
|
||||||
|
args.label = 'topic.reply.title';
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('bookmark', attrs => {
|
||||||
|
if (!attrs.canBookmark) { return; }
|
||||||
|
|
||||||
|
let iconClass = 'read-icon';
|
||||||
|
let buttonClass = 'bookmark';
|
||||||
|
let tooltip = 'bookmarks.not_bookmarked';
|
||||||
|
|
||||||
|
if (attrs.bookmarked) {
|
||||||
|
iconClass += ' bookmarked';
|
||||||
|
buttonClass += ' bookmarked';
|
||||||
|
tooltip = 'bookmarks.created';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'toggleBookmark',
|
||||||
|
title: tooltip,
|
||||||
|
className: buttonClass,
|
||||||
|
contents: h('div', { className: iconClass }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('admin', attrs => {
|
||||||
|
if (!attrs.canManage) { return; }
|
||||||
|
return { action: 'openAdminMenu',
|
||||||
|
title: 'post.controls.admin',
|
||||||
|
className: 'show-post-admin-menu',
|
||||||
|
icon: 'wrench' };
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('delete', attrs => {
|
||||||
|
if (attrs.canRecoverTopic) {
|
||||||
|
return { action: 'recoverPost', title: 'topic.actions.recover', icon: 'undo', className: 'recover' };
|
||||||
|
} else if (attrs.canDeleteTopic) {
|
||||||
|
return { action: 'deletePost', title: 'topic.actions.delete', icon: 'trash-o', className: 'delete' };
|
||||||
|
} else if (attrs.canRecover) {
|
||||||
|
return { action: 'recoverPost', title: 'post.controls.undelete', icon: 'undo', className: 'recover' };
|
||||||
|
} else if (attrs.canDelete) {
|
||||||
|
return { action: 'deletePost', title: 'post.controls.delete', icon: 'trash-o', className: 'delete' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton('wiki', attrs => {
|
||||||
|
if (!attrs.canWiki) { return; }
|
||||||
|
|
||||||
|
if (attrs.wiki) {
|
||||||
|
return { action: 'toggleWiki',
|
||||||
|
title: 'post.controls.unwiki',
|
||||||
|
icon: 'pencil-square-o',
|
||||||
|
className: 'wiki wikied' };
|
||||||
|
} else {
|
||||||
|
return { action: 'toggleWiki',
|
||||||
|
title: 'post.controls.wiki',
|
||||||
|
icon: 'pencil-square-o',
|
||||||
|
className: 'wiki' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('post-menu', {
|
||||||
|
tagName: 'section.post-menu-area.clearfix',
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { collapsed: true, likedUsers: [], adminVisible: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
buildKey: attrs => `post-menu-${attrs.id}`,
|
||||||
|
|
||||||
|
attachButton(name, attrs) {
|
||||||
|
const builder = _builders[name];
|
||||||
|
if (builder) {
|
||||||
|
const buttonAtts = builder(attrs, this.state, this.siteSettings);
|
||||||
|
if (buttonAtts) {
|
||||||
|
return this.attach('button', buttonAtts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const { siteSettings } = this;
|
||||||
|
|
||||||
|
const hiddenSetting = (siteSettings.post_menu_hidden_items || '');
|
||||||
|
const hiddenButtons = hiddenSetting.split('|').filter(s => {
|
||||||
|
return !attrs.bookmarked || s !== 'bookmark';
|
||||||
|
});
|
||||||
|
|
||||||
|
const allButtons = [];
|
||||||
|
let visibleButtons = [];
|
||||||
|
siteSettings.post_menu.split('|').forEach(i => {
|
||||||
|
const button = this.attachButton(i, attrs);
|
||||||
|
if (button) {
|
||||||
|
allButtons.push(button);
|
||||||
|
if ((attrs.yours && button.attrs.alwaysShowYours) ||
|
||||||
|
(attrs.wiki && button.attrs.alwaysShowWiki) ||
|
||||||
|
(hiddenButtons.indexOf(i) === -1)) {
|
||||||
|
visibleButtons.push(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show ellipsis if there is more than one button hidden
|
||||||
|
// if there are no more buttons, we are not collapsed
|
||||||
|
if (!state.collapsed || (allButtons.length <= visibleButtons.length + 1)) {
|
||||||
|
visibleButtons = allButtons;
|
||||||
|
if (state.collapsed) { state.collapsed = false; }
|
||||||
|
} else {
|
||||||
|
const showMore = this.attach('button', {
|
||||||
|
action: 'showMoreActions',
|
||||||
|
title: 'show_more',
|
||||||
|
className: 'show-more-actions',
|
||||||
|
icon: 'ellipsis-h' });
|
||||||
|
visibleButtons.splice(visibleButtons.length - 1, 0, showMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postControls = [];
|
||||||
|
|
||||||
|
const repliesButton = this.attachButton('replies', attrs);
|
||||||
|
if (repliesButton) {
|
||||||
|
postControls.push(repliesButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
postControls.push(h('div.actions', visibleButtons));
|
||||||
|
if (state.adminVisible) {
|
||||||
|
postControls.push(this.attach('post-admin-menu', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = [ h('nav.post-controls.clearfix', postControls) ];
|
||||||
|
if (state.likedUsers.length) {
|
||||||
|
contents.push(this.attach('small-user-list', {
|
||||||
|
users: state.likedUsers,
|
||||||
|
addSelf: attrs.liked,
|
||||||
|
listClassName: 'who-liked',
|
||||||
|
description: 'post.actions.people.like'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
},
|
||||||
|
|
||||||
|
openAdminMenu() {
|
||||||
|
this.state.adminVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAdminMenu() {
|
||||||
|
this.state.adminVisible = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showMoreActions() {
|
||||||
|
this.state.collapsed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
like() {
|
||||||
|
const attrs = this.attrs;
|
||||||
|
if (attrs.liked) {
|
||||||
|
return this.sendWidgetAction('toggleLike');
|
||||||
|
}
|
||||||
|
|
||||||
|
const $heart = $(`[data-post-id=${attrs.id}] .fa-heart`);
|
||||||
|
const scale = [1.0, 1.5];
|
||||||
|
return new Ember.RSVP.Promise(resolve => {
|
||||||
|
animateHeart($heart, scale[0], scale[1], () => {
|
||||||
|
animateHeart($heart, scale[1], scale[0], () => {
|
||||||
|
this.sendWidgetAction('toggleLike').then(() => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleWhoLiked() {
|
||||||
|
const { attrs, state } = this;
|
||||||
|
if (state.likedUsers.length) {
|
||||||
|
state.likedUsers = [];
|
||||||
|
} else {
|
||||||
|
return this.store.find('post-action-user', { id: attrs.id, post_action_type_id: LIKE_ACTION }).then(users => {
|
||||||
|
state.likedUsers = users.map(avatarAtts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import RawHtml from 'discourse/widgets/raw-html';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { actionDescriptionHtml } from 'discourse/components/small-action';
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
'closed.enabled': 'lock',
|
||||||
|
'closed.disabled': 'unlock-alt',
|
||||||
|
'autoclosed.enabled': 'lock',
|
||||||
|
'autoclosed.disabled': 'unlock-alt',
|
||||||
|
'archived.enabled': 'folder',
|
||||||
|
'archived.disabled': 'folder-open',
|
||||||
|
'pinned.enabled': 'thumb-tack',
|
||||||
|
'pinned.disabled': 'thumb-tack unpinned',
|
||||||
|
'pinned_globally.enabled': 'thumb-tack',
|
||||||
|
'pinned_globally.disabled': 'thumb-tack unpinned',
|
||||||
|
'visible.enabled': 'eye',
|
||||||
|
'visible.disabled': 'eye-slash',
|
||||||
|
'split_topic': 'sign-out',
|
||||||
|
'invited_user': 'plus-circle',
|
||||||
|
'removed_user': 'minus-circle'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createWidget('post-small-action', {
|
||||||
|
tagName: 'div.small-action.clearfix',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const contents = [];
|
||||||
|
|
||||||
|
if (attrs.canDelete) {
|
||||||
|
contents.push(this.attach('button', {
|
||||||
|
icon: 'times',
|
||||||
|
action: 'deletePost',
|
||||||
|
title: 'post.controls.delete'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.canEdit) {
|
||||||
|
contents.push(this.attach('button', {
|
||||||
|
icon: 'pencil',
|
||||||
|
action: 'editPost',
|
||||||
|
title: 'post.controls.edit'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = actionDescriptionHtml(attrs.actionCode, attrs.created_at, attrs.actionCodeWho);
|
||||||
|
contents.push(new RawHtml({ html: `<p>${description}</p>` }));
|
||||||
|
|
||||||
|
if (attrs.cooked) {
|
||||||
|
contents.push(new RawHtml({ html: `<div class='custom-message'>${attrs.cooked}</div>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
h('div.topic-avatar', iconNode(icons[attrs.actionCode] || 'exclamation')),
|
||||||
|
h('div.small-action-desc', contents)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
75
app/assets/javascripts/discourse/widgets/post-stream.js.es6
Normal file
75
app/assets/javascripts/discourse/widgets/post-stream.js.es6
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import transformPost from 'discourse/lib/transform-post';
|
||||||
|
|
||||||
|
const DAY = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
export default createWidget('post-stream', {
|
||||||
|
tagName: 'div.post-stream',
|
||||||
|
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const posts = attrs.posts || [];
|
||||||
|
const postArray = posts.toArray();
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {};
|
||||||
|
const after = attrs.gaps && attrs.gaps.before ? attrs.gaps.after : {};
|
||||||
|
|
||||||
|
let prevPost;
|
||||||
|
let prevDate;
|
||||||
|
|
||||||
|
for (let i=0; i<postArray.length; i++) {
|
||||||
|
const post = postArray[i];
|
||||||
|
const nextPost = (i < postArray.length - 1) ? postArray[i+i] : null;
|
||||||
|
|
||||||
|
const transformed = transformPost(this.currentUser, this.site, post, prevPost, nextPost);
|
||||||
|
transformed.canCreatePost = attrs.canCreatePost;
|
||||||
|
|
||||||
|
if (transformed.canManage) {
|
||||||
|
transformed.multiSelect = attrs.multiSelect;
|
||||||
|
|
||||||
|
if (attrs.multiSelect) {
|
||||||
|
transformed.selected = attrs.selectedQuery(post);
|
||||||
|
transformed.selectedPostsCount = attrs.selectedPostsCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.searchService) {
|
||||||
|
transformed.highlightTerm = attrs.searchService.highlightTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post gap - before
|
||||||
|
const beforeGap = before[post.id];
|
||||||
|
if (beforeGap) {
|
||||||
|
result.push(this.attach('post-gap', { pos: 'before', postId: post.id, gap: beforeGap }, { model: post }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle time gaps
|
||||||
|
const curTime = new Date(transformed.created_at).getTime();
|
||||||
|
if (prevDate) {
|
||||||
|
const daysSince = (curTime - prevDate) / DAY;
|
||||||
|
if (daysSince > this.siteSettings.show_time_gap_days) {
|
||||||
|
result.push(this.attach('time-gap', { daysSince }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevDate = curTime;
|
||||||
|
|
||||||
|
// actual post contents
|
||||||
|
if (transformed.isSmallAction) {
|
||||||
|
result.push(this.attach('post-small-action', transformed, { model: post }));
|
||||||
|
} else {
|
||||||
|
result.push(this.attach('post', transformed, { model: post }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post gap - after
|
||||||
|
const afterGap = after[post.id];
|
||||||
|
if (afterGap) {
|
||||||
|
result.push(this.attach('post-gap', { pos: 'after', postId: post.id, gap: afterGap }, { model: post }));
|
||||||
|
}
|
||||||
|
|
||||||
|
prevPost = post;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
395
app/assets/javascripts/discourse/widgets/post.js.es6
Normal file
395
app/assets/javascripts/discourse/widgets/post.js.es6
Normal file
|
@ -0,0 +1,395 @@
|
||||||
|
import PostCooked from 'discourse/widgets/post-cooked';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { transformBasicPost } from 'discourse/lib/transform-post';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import DiscourseURL from 'discourse/lib/url';
|
||||||
|
import { dateNode } from 'discourse/helpers/node';
|
||||||
|
|
||||||
|
export function avatarImg(wanted, attrs) {
|
||||||
|
const size = Discourse.Utilities.translateSize(wanted);
|
||||||
|
const url = Discourse.Utilities.avatarUrl(attrs.template, size);
|
||||||
|
|
||||||
|
// We won't render an invalid url
|
||||||
|
if (!url || url.length === 0) { return; }
|
||||||
|
const title = attrs.username;
|
||||||
|
|
||||||
|
const properties = {
|
||||||
|
attributes: { alt: '', width: size, height: size, src: Discourse.getURLWithCDN(url), title },
|
||||||
|
className: 'avatar'
|
||||||
|
};
|
||||||
|
|
||||||
|
return h('img', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarFor(wanted, attrs) {
|
||||||
|
return h('a', {
|
||||||
|
className: `trigger-user-card ${attrs.className || ''}`,
|
||||||
|
attributes: { href: attrs.url, 'data-user-card': attrs.username }
|
||||||
|
}, avatarImg(wanted, attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget('select-post', {
|
||||||
|
tagName: 'div.select-posts',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
if (attrs.replyCount > 0 && !attrs.selected) {
|
||||||
|
buttons.push(this.attach('button', { label: 'topic.multi_select.select_replies', action: 'selectReplies' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPostKey = attrs.selected ? 'topic.multi_select.selected' : 'topic.multi_select.select';
|
||||||
|
buttons.push(this.attach('button', { className: 'select-post',
|
||||||
|
label: selectPostKey,
|
||||||
|
labelOptions: { count: attrs.selectedPostsCount },
|
||||||
|
action: 'selectPost' }));
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('reply-to-tab', {
|
||||||
|
tagName: 'a.reply-to-tab',
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { loading: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
if (state.loading) { return I18n.t('loading'); }
|
||||||
|
|
||||||
|
return [iconNode('mail-forward'),
|
||||||
|
' ',
|
||||||
|
avatarFor.call(this, 'small', {
|
||||||
|
template: attrs.replyToAvatarTemplate,
|
||||||
|
username: attrs.replyToUsername
|
||||||
|
}),
|
||||||
|
' ',
|
||||||
|
h('span', attrs.replyToUsername)];
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.state.loading = true;
|
||||||
|
this.sendWidgetAction('toggleReplyAbove').then(() => this.state.loading = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('post-avatar', {
|
||||||
|
tagName: 'div.topic-avatar',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
let body;
|
||||||
|
if (!attrs.user_id) {
|
||||||
|
body = h('i', { className: 'fa fa-trash-o deleted-user-avatar' });
|
||||||
|
} else {
|
||||||
|
body = avatarFor.call(this, 'large', {
|
||||||
|
template: attrs.avatar_template,
|
||||||
|
username: attrs.username,
|
||||||
|
url: attrs.usernameUrl,
|
||||||
|
className: 'main-avatar'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [body, h('div.poster-avatar-extra')];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
createWidget('wiki-edit-button', {
|
||||||
|
tagName: 'div.post-info.wiki',
|
||||||
|
title: 'post.wiki.about',
|
||||||
|
|
||||||
|
html() {
|
||||||
|
return iconNode('pencil-square-o');
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction('editPost');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('post-email-indicator', {
|
||||||
|
tagName: 'div.post-info.via-email',
|
||||||
|
title: 'post.via_email',
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
return attrs.canViewRawEmail ? 'raw-email' : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
html() {
|
||||||
|
return iconNode('envelope-o');
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
if (this.attrs.canViewRawEmail) {
|
||||||
|
this.sendWidgetAction('showRawEmail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showReplyTab(attrs, siteSettings) {
|
||||||
|
return attrs.replyToUsername &&
|
||||||
|
(!attrs.replyDirectlyAbove || !siteSettings.suppress_reply_directly_above);
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget('post-meta-data', {
|
||||||
|
tagName: 'div.topic-meta-data',
|
||||||
|
html(attrs) {
|
||||||
|
const result = [this.attach('poster-name', attrs)];
|
||||||
|
|
||||||
|
if (attrs.isWhisper) {
|
||||||
|
result.push(h('div.post-info.whisper', {
|
||||||
|
attributes: { title: I18n.t('post.whisper') },
|
||||||
|
}, iconNode('eye-slash')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(attrs.created_at);
|
||||||
|
if (createdAt) {
|
||||||
|
result.push(h('div.post-info',
|
||||||
|
h('a.post-date', {
|
||||||
|
attributes: {
|
||||||
|
href: attrs.shareUrl,
|
||||||
|
'data-share-url': attrs.shareUrl,
|
||||||
|
'data-post-number': attrs.post_number
|
||||||
|
}
|
||||||
|
}, dateNode(createdAt))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.via_email) {
|
||||||
|
result.push(this.attach('post-email-indicator', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.version > 1) {
|
||||||
|
result.push(this.attach('post-edits-indicator', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.wiki) {
|
||||||
|
result.push(this.attach('wiki-edit-button', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.multiSelect) {
|
||||||
|
result.push(this.attach('select-post', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showReplyTab(attrs, this.siteSettings)) {
|
||||||
|
result.push(this.attach('reply-to-tab', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(h('div.read-state', {
|
||||||
|
className: attrs.read ? 'read' : null,
|
||||||
|
attributes: {
|
||||||
|
title: I18n.t('post.unread')
|
||||||
|
}
|
||||||
|
}, iconNode('circle')));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('expand-hidden', {
|
||||||
|
tagName: 'a.expand-hidden',
|
||||||
|
|
||||||
|
html() {
|
||||||
|
return I18n.t('post.show_hidden');
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction('expandHidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('expand-post-button', {
|
||||||
|
tagName: 'button.btn.expand-post',
|
||||||
|
buildKey: attrs => `expand-post-button-${attrs.id}`,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { loadingExpanded: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
if (state.loadingExpanded) {
|
||||||
|
return I18n.t('loading');
|
||||||
|
} else {
|
||||||
|
return [I18n.t('post.show_full'), "..."];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.state.loadingExpanded = true;
|
||||||
|
this.sendWidgetAction('expandFirstPost');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('post-contents', {
|
||||||
|
buildKey: attrs => `post-contents-${attrs.id}`,
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
const classes = [];
|
||||||
|
if (!this.state.repliesShown) {
|
||||||
|
classes.push('contents');
|
||||||
|
}
|
||||||
|
if (showReplyTab(attrs, this.siteSettings)) {
|
||||||
|
classes.push('avoid-tab');
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
return new PostCooked(attrs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('post-body', {
|
||||||
|
tagName: 'div.topic-body',
|
||||||
|
buildKey: attrs => `topic-body-${attrs.id}`,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { expandedFirstPost: false, repliesBelow: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const extraState = { state: { repliesShown: !!state.repliesBelow.length } };
|
||||||
|
const regular = [this.attach('post-contents', attrs, extraState)];
|
||||||
|
|
||||||
|
if (attrs.cooked_hidden) {
|
||||||
|
regular.push(this.attach('expand-hidden', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.expandedFirstPost && attrs.expandablePost) {
|
||||||
|
regular.push(this.attach('expand-post-button', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
regular.push(this.attach('post-menu', attrs, extraState));
|
||||||
|
|
||||||
|
const repliesBelow = state.repliesBelow;
|
||||||
|
if (repliesBelow.length) {
|
||||||
|
regular.push(h('section.embedded-posts.bottom',
|
||||||
|
repliesBelow.map(p => this.attach('embedded-post', p))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [this.attach('post-meta-data', attrs), h('div.regular', regular)];
|
||||||
|
result.push(this.attach('actions-summary', attrs));
|
||||||
|
if (attrs.showTopicMap) {
|
||||||
|
result.push(this.attach('topic-map', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleRepliesBelow() {
|
||||||
|
if (this.state.repliesBelow.length) {
|
||||||
|
this.state.repliesBelow = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.store.find('post-reply', { postId: this.attrs.id }).then(posts => {
|
||||||
|
this.state.repliesBelow = posts.map(transformBasicPost);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
expandFirstPost() {
|
||||||
|
const post = this.findAncestorModel();
|
||||||
|
return post.expand().then(() => this.state.expandedFirstPost = true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('post-article', {
|
||||||
|
tagName: 'article.boxed',
|
||||||
|
buildKey: attrs => `post-article-${attrs.id}`,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { repliesAbove: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
buildId(attrs) {
|
||||||
|
return `post_${attrs.post_number}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
if (attrs.via_email) { return 'via-email'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAttributes(attrs) {
|
||||||
|
return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const rows = [];
|
||||||
|
if (state.repliesAbove.length) {
|
||||||
|
const replies = state.repliesAbove.map(p => this.attach('embedded-post', p, { state: { above: true } }));
|
||||||
|
rows.push(h('div.row', h('section.embedded-posts.top.topic-body.offset2', replies)));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(h('div.row', [this.attach('post-avatar', attrs),
|
||||||
|
this.attach('post-body', attrs),
|
||||||
|
this.attach('post-gutter', attrs)]));
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleReplyAbove() {
|
||||||
|
const replyPostNumber = this.attrs.reply_to_post_number;
|
||||||
|
|
||||||
|
// jump directly on mobile
|
||||||
|
if (Discourse.Mobile.mobileView) {
|
||||||
|
DiscourseURL.jumpToPost(replyPostNumber);
|
||||||
|
return Ember.RSVP.Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.repliesAbove.length) {
|
||||||
|
this.state.repliesAbove = [];
|
||||||
|
return Ember.RSVP.Promise.resolve();
|
||||||
|
} else {
|
||||||
|
return this.store.find('post-reply-history', { postId: this.attrs.id }).then(posts => {
|
||||||
|
this.state.repliesAbove = posts.map(transformBasicPost);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('post', {
|
||||||
|
buildKey: attrs => `post-${attrs.id}`,
|
||||||
|
shadowTree: true,
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
const classNames = ['topic-post', 'clearfix'];
|
||||||
|
|
||||||
|
if (attrs.selected) { classNames.push('selected'); }
|
||||||
|
if (attrs.topicOwner) { classNames.push('topic-owner'); }
|
||||||
|
if (attrs.hidden) { classNames.push('post-hidden'); }
|
||||||
|
if (attrs.deleted) { classNames.push('deleted'); }
|
||||||
|
if (attrs.primary_group_name) { classNames.push(`group-${attrs.primary_group_name}`); }
|
||||||
|
if (attrs.wiki) { classNames.push(`wiki`); }
|
||||||
|
if (attrs.isWhisper) { classNames.push('whisper'); }
|
||||||
|
if (attrs.isModeratorAction || (attrs.isWarning && attrs.firstPost)) {
|
||||||
|
classNames.push('moderator');
|
||||||
|
} else {
|
||||||
|
classNames.push('regular');
|
||||||
|
}
|
||||||
|
return classNames;
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
return this.attach('post-article', attrs);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLike() {
|
||||||
|
const post = this.model;
|
||||||
|
const likeAction = post.get('likeAction');
|
||||||
|
|
||||||
|
if (likeAction && likeAction.get('canToggle')) {
|
||||||
|
return likeAction.togglePromise(post);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
undoPostAction(typeId) {
|
||||||
|
const post = this.model;
|
||||||
|
return post.get('actions_summary').findProperty('id', typeId).undo(post);
|
||||||
|
},
|
||||||
|
|
||||||
|
deferPostActionFlags(typeId) {
|
||||||
|
const post = this.model;
|
||||||
|
return post.get('actions_summary').findProperty('id', typeId).deferFlags(post);
|
||||||
|
}
|
||||||
|
});
|
61
app/assets/javascripts/discourse/widgets/poster-name.js.es6
Normal file
61
app/assets/javascripts/discourse/widgets/poster-name.js.es6
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
function sanitizeName(name){
|
||||||
|
return name.toLowerCase().replace(/[\s_-]/g,'');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createWidget('poster-name', {
|
||||||
|
tagName: 'div.names.trigger-user-card',
|
||||||
|
|
||||||
|
// TODO: Allow extensibility
|
||||||
|
posterGlyph(attrs) {
|
||||||
|
if (attrs.moderator) {
|
||||||
|
return iconNode('shield', { title: I18n.t('user.moderator_tooltip') });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
userLink(attrs, text) {
|
||||||
|
return h('a', { attributes: {
|
||||||
|
href: attrs.usernameUrl,
|
||||||
|
'data-auto-route': true,
|
||||||
|
'data-user-card': attrs.username
|
||||||
|
} }, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const username = attrs.username;
|
||||||
|
const classNames = ['username'];
|
||||||
|
|
||||||
|
if (attrs.staff) { classNames.push('staff'); }
|
||||||
|
if (attrs.admin) { classNames.push('admin'); }
|
||||||
|
if (attrs.moderator) { classNames.push('moderator'); }
|
||||||
|
if (attrs.new_user) { classNames.push('new-user'); }
|
||||||
|
|
||||||
|
const primaryGroupName = attrs.primary_group_name;
|
||||||
|
if (primaryGroupName && primaryGroupName.length) {
|
||||||
|
classNames.push(primaryGroupName);
|
||||||
|
}
|
||||||
|
const nameContents = [ this.userLink(attrs, attrs.username) ];
|
||||||
|
const glyph = this.posterGlyph(attrs);
|
||||||
|
if (glyph) { nameContents.push(glyph); }
|
||||||
|
|
||||||
|
const contents = [h('span', { className: classNames.join(' ') }, nameContents)];
|
||||||
|
const name = attrs.name;
|
||||||
|
if (name && this.siteSettings.display_name_on_posts && sanitizeName(name) !== sanitizeName(username)) {
|
||||||
|
contents.push(h('span.full-name', this.userLink(attrs, name)));
|
||||||
|
}
|
||||||
|
const title = attrs.user_title;
|
||||||
|
if (title && title.length) {
|
||||||
|
let titleContents = title;
|
||||||
|
if (primaryGroupName) {
|
||||||
|
const href = Discourse.getURL(`/groups/${primaryGroupName}`);
|
||||||
|
titleContents = h('a.user-group', { attributes: { href } }, title);
|
||||||
|
}
|
||||||
|
contents.push(h('span.user-title', titleContents));
|
||||||
|
}
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { avatarFor } from 'discourse/widgets/post';
|
||||||
|
|
||||||
|
createWidget('pm-map-user-group', {
|
||||||
|
tagName: 'div.user.group',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const link = h('a', { attributes: { href: Discourse.getURL(`/groups/${attrs.name}`) } }, attrs.name);
|
||||||
|
return [iconNode('users'), ' ', link];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('pm-remove-link', {
|
||||||
|
tagName: 'a.remove-invited',
|
||||||
|
|
||||||
|
html() {
|
||||||
|
return iconNode('times');
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
bootbox.confirm(I18n.t("private_message_info.remove_allowed_user", {name: this.attrs.username}),
|
||||||
|
() => this.sendWidgetAction('removeAllowedUser', this.attrs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('pm-map-user', {
|
||||||
|
tagName: 'div.user',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const user = attrs.user;
|
||||||
|
const avatar = avatarFor('small', { template: user.avatar_template, username: user.username });
|
||||||
|
const link = h('a', { attributes: { href: user.get('path') } }, [ avatar, ' ', user.username ]);
|
||||||
|
|
||||||
|
const result = [link];
|
||||||
|
if (attrs.canRemoveAllowedUsers) {
|
||||||
|
result.push(' ');
|
||||||
|
result.push(this.attach('pm-remove-link', user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('private-message-map', {
|
||||||
|
tagName: 'section.information.private-message-map',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const participants = [];
|
||||||
|
|
||||||
|
if (attrs.allowedGroups.length) {
|
||||||
|
participants.push(attrs.allowedGroups.map(ag => this.attach('pm-map-user-group', ag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.allowedUsers.length) {
|
||||||
|
participants.push(attrs.allowedUsers.map(ag => {
|
||||||
|
return this.attach('pm-map-user', { user: ag, canRemoveAllowedUsers: attrs.canRemoveAllowedUsers });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [ h('h3', [iconNode('envelope'), ' ', I18n.t('private_message_info.title')]),
|
||||||
|
h('div.participants.clearfix', participants) ];
|
||||||
|
|
||||||
|
if (attrs.canInvite) {
|
||||||
|
result.push(h('div.controls', this.attach('button', {
|
||||||
|
action: 'showInvite',
|
||||||
|
label: 'private_message_info.invite',
|
||||||
|
className: 'btn'
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
18
app/assets/javascripts/discourse/widgets/raw-html.js.es6
Normal file
18
app/assets/javascripts/discourse/widgets/raw-html.js.es6
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export default class RawHtml {
|
||||||
|
constructor(attrs) {
|
||||||
|
this.html = attrs.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
return $(this.html)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
update(prev) {
|
||||||
|
if (prev.html === this.html) { return; }
|
||||||
|
return this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
RawHtml.prototype.type = 'Widget';
|
26
app/assets/javascripts/discourse/widgets/time-gap.js.es6
Normal file
26
app/assets/javascripts/discourse/widgets/time-gap.js.es6
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { iconNode } from 'discourse/helpers/fa-icon';
|
||||||
|
|
||||||
|
function description(attrs) {
|
||||||
|
const daysSince = attrs.daysSince;
|
||||||
|
|
||||||
|
if (daysSince < 30) {
|
||||||
|
return I18n.t('dates.later.x_days', {count: daysSince});
|
||||||
|
} else if (daysSince < 365) {
|
||||||
|
const gapMonths = Math.floor(daysSince / 30);
|
||||||
|
return I18n.t('dates.later.x_months', {count: gapMonths});
|
||||||
|
} else {
|
||||||
|
const gapYears = Math.floor(daysSince / 365);
|
||||||
|
return I18n.t('dates.later.x_years', {count: gapYears});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createWidget('time-gap', {
|
||||||
|
tagName: 'div.time-gap.small-action',
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
return [h('div.topic-avatar', iconNode('clock-o')),
|
||||||
|
h('div.small-action-desc', description(attrs))];
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,33 @@
|
||||||
|
import RawHtml from 'discourse/widgets/raw-html';
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
|
||||||
|
createWidget('toggle-summary-description', {
|
||||||
|
description(attrs) {
|
||||||
|
if (attrs.topicSummaryEnabled) {
|
||||||
|
return I18n.t('summary.enabled_description');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.topicWordCount) {
|
||||||
|
const readingTime = Math.floor(attrs.topicWordCount / this.siteSettings.read_time_word_count);
|
||||||
|
return I18n.t('summary.description_time', { count: attrs.topicPostsCount, readingTime });
|
||||||
|
}
|
||||||
|
return I18n.t('summary.description', { count: attrs.topicPostsCount });
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
// vdom makes putting html in the i18n difficult
|
||||||
|
return new RawHtml({ html: `<p>${this.description(attrs)}</p>` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('toggle-topic-summary', {
|
||||||
|
tagName: 'section.information.toggle-summary',
|
||||||
|
html(attrs) {
|
||||||
|
return [ this.attach('toggle-summary-description', attrs),
|
||||||
|
this.attach('button', {
|
||||||
|
className: 'btn btn-primary',
|
||||||
|
label: attrs.topicSummaryEnabled ? 'summary.disable' : 'summary.enable',
|
||||||
|
action: 'toggleSummary'
|
||||||
|
}) ];
|
||||||
|
}
|
||||||
|
});
|
219
app/assets/javascripts/discourse/widgets/topic-map.js.es6
Normal file
219
app/assets/javascripts/discourse/widgets/topic-map.js.es6
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import { createWidget } from 'discourse/widgets/widget';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
import { avatarImg, avatarFor } from 'discourse/widgets/post';
|
||||||
|
import { dateNode, numberNode } from 'discourse/helpers/node';
|
||||||
|
|
||||||
|
const LINKS_SHOWN = 5;
|
||||||
|
|
||||||
|
function renderParticipants(userFilters, participants) {
|
||||||
|
if (!participants) { return; }
|
||||||
|
|
||||||
|
userFilters = userFilters || [];
|
||||||
|
return participants.map(p => {
|
||||||
|
return this.attach('topic-participant', p, { state: { toggled: userFilters.contains(p.username) } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget('topic-map-show-links', {
|
||||||
|
tagName: 'div.link-summary',
|
||||||
|
html(attrs) {
|
||||||
|
return h('a', I18n.t('topic_map.links_shown', { totalLinks: attrs.totalLinks }));
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction('showAllLinks');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('topic-participant', {
|
||||||
|
html(attrs, state) {
|
||||||
|
const linkContents = [avatarImg('medium', { username: attrs.username, template: attrs.avatar_template })];
|
||||||
|
|
||||||
|
if (attrs.post_count > 2) {
|
||||||
|
linkContents.push(h('span.post-count', attrs.post_count.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('a.poster', { className: state.toggled ? 'toggled' : null, attributes: { title: attrs.username } },
|
||||||
|
linkContents
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.sendWidgetAction('toggleParticipant', this.attrs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('topic-map-summary', {
|
||||||
|
tagName: 'section.map',
|
||||||
|
|
||||||
|
buildClasses(attrs, state) {
|
||||||
|
if (state.collapsed) { return 'map-collapsed'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const contents = [];
|
||||||
|
contents.push(h('li',
|
||||||
|
[
|
||||||
|
h('h4', I18n.t('created_lowercase')),
|
||||||
|
avatarFor('tiny', { username: attrs.createdByUsername, template: attrs.createdByAvatarTemplate }),
|
||||||
|
dateNode(attrs.topicCreatedAt)
|
||||||
|
]
|
||||||
|
));
|
||||||
|
contents.push(h('li',
|
||||||
|
h('a', { attributes: { href: attrs.lastPostUrl } }, [
|
||||||
|
h('h4', I18n.t('last_reply_lowercase')),
|
||||||
|
avatarFor('tiny', { username: attrs.lastPostUsername, template: attrs.lastPostAvatarTemplate }),
|
||||||
|
dateNode(attrs.lastPostAt)
|
||||||
|
])
|
||||||
|
));
|
||||||
|
contents.push(h('li', [
|
||||||
|
numberNode(attrs.topicReplyCount),
|
||||||
|
h('h4', I18n.t('replies_lowercase', { count: attrs.topicReplyCount }))
|
||||||
|
]));
|
||||||
|
contents.push(h('li.secondary', [
|
||||||
|
numberNode(attrs.topicViews, { className: attrs.topicViewsHeat }),
|
||||||
|
h('h4', I18n.t('views_lowercase', { count: attrs.topicViews }))
|
||||||
|
]));
|
||||||
|
contents.push(h('li.secondary', [
|
||||||
|
numberNode(attrs.participantCount),
|
||||||
|
h('h4', I18n.t('users_lowercase', { count: attrs.participantCount }))
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (attrs.topicLikeCount) {
|
||||||
|
contents.push(h('li.secondary', [
|
||||||
|
numberNode(attrs.likeCount),
|
||||||
|
h('h4', I18n.t('likes_lowercase', { count: attrs.likeCount }))
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.topicLinkLength > 0) {
|
||||||
|
contents.push(h('li.secondary', [
|
||||||
|
numberNode(attrs.topicLinkLength),
|
||||||
|
h('h4', I18n.t('links_lowercase', { count: attrs.topicLinkLength }))
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.collapsed && attrs.topicPostsCount > 2 && attrs.participants.length > 0) {
|
||||||
|
const participants = renderParticipants.call(this, attrs.userFilters, attrs.participants.slice(0, 3));
|
||||||
|
contents.push(h('li.avatars', participants));
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('ul.clearfix', contents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('topic-map-link', {
|
||||||
|
tagName: 'a.topic-link.track-link',
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
if (attrs.attachment) { return 'attachment'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAttributes(attrs) {
|
||||||
|
return { href: attrs.url,
|
||||||
|
target: "_blank",
|
||||||
|
'data-user-id': attrs.user_id,
|
||||||
|
'data-ignore-post-id': 'true',
|
||||||
|
title: attrs.url };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
if (attrs.title) { return attrs.title; }
|
||||||
|
return attrs.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createWidget('topic-map-expanded', {
|
||||||
|
tagName: 'section.topic-map-expanded',
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { allLinksShown: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const avatars = h('section.avatars.clearfix', [
|
||||||
|
h('h3', I18n.t('topic_map.participants_title')),
|
||||||
|
renderParticipants.call(this, attrs.userFilters, attrs.participants)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = [avatars];
|
||||||
|
if (attrs.topicLinks) {
|
||||||
|
|
||||||
|
const toShow = state.allLinksShown ? attrs.topicLinks : attrs.topicLinks.slice(0, LINKS_SHOWN);
|
||||||
|
const links = toShow.map(l => {
|
||||||
|
|
||||||
|
let host = '';
|
||||||
|
if (l.title && l.title.length) {
|
||||||
|
const domain = l.domain;
|
||||||
|
if (domain && domain.length) {
|
||||||
|
const s = domain.split('.');
|
||||||
|
host = h('span.domain', s[s.length-2] + "." + s[s.length-1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('tr', [
|
||||||
|
h('td',
|
||||||
|
h('span.badge.badge-notification.clicks', {
|
||||||
|
attributes: { title: I18n.t('topic_map.clicks', { count: l.clicks }) }
|
||||||
|
}, l.clicks.toString())
|
||||||
|
),
|
||||||
|
h('td', [this.attach('topic-map-link', l), ' ', host])
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAllLinksContent = [
|
||||||
|
h('h3', I18n.t('topic_map.links_title')),
|
||||||
|
h('table.topic-links', links)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!state.allLinksShown && links.length < attrs.topicLinks.length) {
|
||||||
|
showAllLinksContent.push(this.attach('topic-map-show-links', { totalLinks: attrs.topicLinks.length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = h('section.links', showAllLinksContent);
|
||||||
|
result.push(section);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
showAllLinks() {
|
||||||
|
this.state.allLinksShown = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default createWidget('topic-map', {
|
||||||
|
tagName: 'div.topic-map',
|
||||||
|
buildKey: attrs => `topic-map-${attrs.id}`,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { collapsed: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs, state) {
|
||||||
|
const nav = h('nav.buttons', this.attach('button', {
|
||||||
|
title: 'topic.toggle_information',
|
||||||
|
icon: state.collapsed ? 'chevron-down' : 'chevron-up',
|
||||||
|
action: 'toggleMap',
|
||||||
|
className: 'btn',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contents = [nav, this.attach('topic-map-summary', attrs, { state })];
|
||||||
|
|
||||||
|
if (!state.collapsed) {
|
||||||
|
contents.push(this.attach('topic-map-expanded', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.hasTopicSummary) {
|
||||||
|
contents.push(this.attach('toggle-topic-summary', attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.showPMMap) {
|
||||||
|
contents.push(this.attach('private-message-map', attrs));
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMap() {
|
||||||
|
this.state.collapsed = !this.state.collapsed;
|
||||||
|
}
|
||||||
|
});
|
214
app/assets/javascripts/discourse/widgets/widget.js.es6
Normal file
214
app/assets/javascripts/discourse/widgets/widget.js.es6
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
import { WidgetClickHook, WidgetClickOutsideHook } from 'discourse/widgets/click-hook';
|
||||||
|
import { h } from 'virtual-dom';
|
||||||
|
|
||||||
|
function emptyContent() { }
|
||||||
|
|
||||||
|
const _registry = {};
|
||||||
|
const _dirty = {};
|
||||||
|
|
||||||
|
export function keyDirty(key) {
|
||||||
|
_dirty[key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWidget(builder, attrs, state) {
|
||||||
|
const properties = {};
|
||||||
|
|
||||||
|
if (this.buildClasses) {
|
||||||
|
let classes = this.buildClasses(attrs, state) || [];
|
||||||
|
if (!Array.isArray(classes)) { classes = [classes]; }
|
||||||
|
if (classes.length) {
|
||||||
|
properties.className = classes.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.buildId) {
|
||||||
|
properties.id = this.buildId(attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildAttributes) {
|
||||||
|
properties.attributes = this.buildAttributes(attrs);
|
||||||
|
}
|
||||||
|
if (this.clickOutside) {
|
||||||
|
properties['widget-click-outside'] = new WidgetClickOutsideHook(this);
|
||||||
|
}
|
||||||
|
if (this.click) {
|
||||||
|
properties['widget-click'] = new WidgetClickHook(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = properties['attributes'] || {};
|
||||||
|
properties.attributes = attributes;
|
||||||
|
if (this.title) {
|
||||||
|
attributes.title = I18n.t(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(this.tagName || 'div', properties, this.html(attrs, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWidget(name, opts) {
|
||||||
|
const result = class CustomWidget extends Widget {};
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
_registry[name] = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.html = opts.html || emptyContent;
|
||||||
|
opts.draw = drawWidget;
|
||||||
|
|
||||||
|
Object.keys(opts).forEach(k => result.prototype[k] = opts[k]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Widget {
|
||||||
|
constructor(attrs, container, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
this.attrs = attrs || {};
|
||||||
|
this.mergeState = opts.state;
|
||||||
|
this.container = container;
|
||||||
|
this.model = opts.model;
|
||||||
|
|
||||||
|
this.key = this.buildKey ? this.buildKey(attrs) : null;
|
||||||
|
|
||||||
|
this.site = container.lookup('site:main');
|
||||||
|
this.siteSettings = container.lookup('site-settings:main');
|
||||||
|
this.currentUser = container.lookup('current-user:main');
|
||||||
|
this.store = container.lookup('store:main');
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
console.log('destroy called');
|
||||||
|
}
|
||||||
|
|
||||||
|
render(prev) {
|
||||||
|
if (prev && prev.state) {
|
||||||
|
this.state = prev.state;
|
||||||
|
} else {
|
||||||
|
this.state = this.defaultState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sometimes we pass state down from the parent
|
||||||
|
if (this.mergeState) {
|
||||||
|
this.state = _.merge(this.state, this.mergeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev && prev.shadowTree) {
|
||||||
|
this.shadowTree = true;
|
||||||
|
if (!_dirty[prev.key]) { return prev.vnode; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.draw(h, this.attrs, this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
_findAncestorWithProperty(property) {
|
||||||
|
let widget = this;
|
||||||
|
while (widget) {
|
||||||
|
const value = widget[property];
|
||||||
|
if (value) {
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
widget = widget.parentWidget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_findView() {
|
||||||
|
const widget = this._findAncestorWithProperty('_emberView');
|
||||||
|
if (widget) {
|
||||||
|
return widget._emberView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(widgetName, attrs, opts) {
|
||||||
|
let WidgetClass = _registry[widgetName];
|
||||||
|
|
||||||
|
if (!WidgetClass) {
|
||||||
|
if (!this.container) {
|
||||||
|
console.error("couldn't find container");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetClass = this.container.lookupFactory(`widget:${widgetName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WidgetClass) {
|
||||||
|
const result = new WidgetClass(attrs, this.container, opts);
|
||||||
|
result.parentWidget = this;
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
throw `Couldn't find ${widgetName} factory`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRerender() {
|
||||||
|
let widget = this;
|
||||||
|
while (widget) {
|
||||||
|
if (widget.shadowTree) {
|
||||||
|
keyDirty(widget.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emberView = widget._emberView;
|
||||||
|
if (emberView) {
|
||||||
|
return emberView.queueRerender();
|
||||||
|
}
|
||||||
|
widget = widget.parentWidget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendComponentAction(name, param) {
|
||||||
|
const view = this._findAncestorWithProperty('_emberView');
|
||||||
|
|
||||||
|
let promise;
|
||||||
|
if (view) {
|
||||||
|
// Peek into ember internals to allow us to return promises from actions
|
||||||
|
const ev = view._emberView;
|
||||||
|
const target = ev.get('targetObject');
|
||||||
|
|
||||||
|
const actionName = ev.get(name);
|
||||||
|
if (!actionName) {
|
||||||
|
Ember.warn(`${name} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const actions = target._actions || target.actionHooks;
|
||||||
|
const method = actions[actionName];
|
||||||
|
if (method) {
|
||||||
|
promise = method.call(target, param);
|
||||||
|
if (!promise || !promise.then) {
|
||||||
|
promise = Ember.RSVP.resolve(promise);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ev.sendAction(name, param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promise) {
|
||||||
|
return promise.then(() => this.scheduleRerender());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findAncestorModel() {
|
||||||
|
const modelWidget = this._findAncestorWithProperty('model');
|
||||||
|
if (modelWidget) {
|
||||||
|
return modelWidget.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendWidgetAction(name, param) {
|
||||||
|
const widget = this._findAncestorWithProperty(name);
|
||||||
|
if (widget) {
|
||||||
|
const result = widget[name](param);
|
||||||
|
if (result && result.then) {
|
||||||
|
return result.then(() => this.scheduleRerender());
|
||||||
|
} else {
|
||||||
|
this.scheduleRerender();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendComponentAction(name, param || this.findAncestorModel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget.prototype.type = 'Thunk';
|
|
@ -58,11 +58,9 @@
|
||||||
//= require ./discourse/models/user-badge
|
//= require ./discourse/models/user-badge
|
||||||
//= require ./discourse/controllers/discovery-sortable
|
//= require ./discourse/controllers/discovery-sortable
|
||||||
//= require ./discourse/controllers/navigation/default
|
//= require ./discourse/controllers/navigation/default
|
||||||
//= require ./discourse/views/grouped
|
|
||||||
//= require ./discourse/views/container
|
//= require ./discourse/views/container
|
||||||
//= require ./discourse/views/modal-body
|
//= require ./discourse/views/modal-body
|
||||||
//= require ./discourse/views/flag
|
//= require ./discourse/views/flag
|
||||||
//= require ./discourse/views/cloaked
|
|
||||||
//= require ./discourse/components/combo-box
|
//= require ./discourse/components/combo-box
|
||||||
//= require ./discourse/components/edit-category-panel
|
//= require ./discourse/components/edit-category-panel
|
||||||
//= require ./discourse/views/button
|
//= require ./discourse/views/button
|
||||||
|
@ -110,3 +108,4 @@
|
||||||
//= require_tree ./discourse/pre-initializers
|
//= require_tree ./discourse/pre-initializers
|
||||||
//= require_tree ./discourse/initializers
|
//= require_tree ./discourse/initializers
|
||||||
//= require_tree ./discourse/services
|
//= require_tree ./discourse/services
|
||||||
|
//= require_tree ./discourse/widgets
|
||||||
|
|
|
@ -38,4 +38,6 @@
|
||||||
//= require break_string
|
//= require break_string
|
||||||
//= require buffered-proxy
|
//= require buffered-proxy
|
||||||
//= require jquery.autoellipsis-1.0.10.min.js
|
//= require jquery.autoellipsis-1.0.10.min.js
|
||||||
|
//= require virtual-dom
|
||||||
|
//= require virtual-dom-amd
|
||||||
//= require_tree ./discourse/ember
|
//= require_tree ./discourse/ember
|
||||||
|
|
|
@ -19,7 +19,7 @@ h1 .topic-statuses .topic-status i {
|
||||||
max-height: 40px;
|
max-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-cloak {
|
.topic-body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&:first-of-type {
|
&:first-of-type {
|
||||||
|
@ -208,7 +208,6 @@ nav.post-controls {
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: none;
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
@ -1003,9 +1002,9 @@ and (max-width : 870px) {
|
||||||
width: 45px;
|
width: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-cloak .reply-to-tab {
|
.topic-post .reply-to-tab {
|
||||||
right: 15%;
|
right: 15%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-body {
|
.topic-body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
|
@ -163,7 +163,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-cloak:last-of-type {padding-bottom: 40px;}
|
.topic-post:last-of-type {padding-bottom: 40px;}
|
||||||
|
|
||||||
.heatmap-high {color: scale-color($danger, $lightness: -25%) !important;}
|
.heatmap-high {color: scale-color($danger, $lightness: -25%) !important;}
|
||||||
.heatmap-med {color: $danger !important;}
|
.heatmap-med {color: $danger !important;}
|
||||||
|
|
|
@ -1530,17 +1530,14 @@ en:
|
||||||
like: "Undo like"
|
like: "Undo like"
|
||||||
vote: "Undo vote"
|
vote: "Undo vote"
|
||||||
people:
|
people:
|
||||||
off_topic: "{{icons}} flagged this as off-topic"
|
off_topic: "flagged this as off-topic"
|
||||||
spam: "{{icons}} flagged this as spam"
|
spam: "flagged this as spam"
|
||||||
spam_with_url: "{{icons}} flagged <a href='{{postUrl}}'>this as spam</a>"
|
inappropriate: "flagged this as inappropriate"
|
||||||
inappropriate: "{{icons}} flagged this as inappropriate"
|
notify_moderators: "notified moderators"
|
||||||
notify_moderators: "{{icons}} notified moderators"
|
notify_user: "sent a message"
|
||||||
notify_moderators_with_url: "{{icons}} <a href='{{postUrl}}'>notified moderators</a>"
|
bookmark: "bookmarked this"
|
||||||
notify_user: "{{icons}} sent a message"
|
like: "liked this"
|
||||||
notify_user_with_url: "{{icons}} sent a <a href='{{postUrl}}'>message</a>"
|
vote: "voted for this"
|
||||||
bookmark: "{{icons}} bookmarked this"
|
|
||||||
like: "{{icons}} liked this"
|
|
||||||
vote: "{{icons}} voted for this"
|
|
||||||
by_you:
|
by_you:
|
||||||
off_topic: "You flagged this as off-topic"
|
off_topic: "You flagged this as off-topic"
|
||||||
spam: "You flagged this as spam"
|
spam: "You flagged this as spam"
|
||||||
|
|
|
@ -928,7 +928,6 @@ ar:
|
||||||
suppress_reply_directly_above: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب."
|
suppress_reply_directly_above: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب."
|
||||||
suppress_reply_when_quoting: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب."
|
suppress_reply_when_quoting: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب."
|
||||||
max_reply_history: "الحد الأقصى لعدد الردود على توسيع عند توسيع في الرد ل"
|
max_reply_history: "الحد الأقصى لعدد الردود على توسيع عند توسيع في الرد ل"
|
||||||
experimental_reply_expansion: "إخفاء ردود المتوسطة عند توسيع ردا على (تجريبي)"
|
|
||||||
topics_per_period_in_top_summary: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع."
|
topics_per_period_in_top_summary: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع."
|
||||||
topics_per_period_in_top_page: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع."
|
topics_per_period_in_top_page: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع."
|
||||||
redirect_users_to_top_page: "إعادة توجيه تلقائيا للمستخدمين الجدد وغائبة لمدة طويلة إلى أعلى الصفحة."
|
redirect_users_to_top_page: "إعادة توجيه تلقائيا للمستخدمين الجدد وغائبة لمدة طويلة إلى أعلى الصفحة."
|
||||||
|
|
|
@ -758,7 +758,6 @@ de:
|
||||||
suppress_reply_directly_above: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der beantwortete Beitrag direkt darüber angezeigt wird."
|
suppress_reply_directly_above: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der beantwortete Beitrag direkt darüber angezeigt wird."
|
||||||
suppress_reply_when_quoting: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der Beitrag den beantworteten Beitrag zitiert."
|
suppress_reply_when_quoting: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der Beitrag den beantworteten Beitrag zitiert."
|
||||||
max_reply_history: "Maximale Anzahl an Antworten beim Ausklappen von in-reply-to"
|
max_reply_history: "Maximale Anzahl an Antworten beim Ausklappen von in-reply-to"
|
||||||
experimental_reply_expansion: "Verstecke dazwischenliegende Beiträge, wenn der beantwortete Beitrag erweitert wird (experimentell)."
|
|
||||||
topics_per_period_in_top_summary: "Anzahl der Themen, die in der Top-Themübersicht angezeigt werden."
|
topics_per_period_in_top_summary: "Anzahl der Themen, die in der Top-Themübersicht angezeigt werden."
|
||||||
topics_per_period_in_top_page: "Anzahl der Themen, die in der mit \"Mehr zeigen\" erweiterten Top-Themenübersicht angezeigt werden."
|
topics_per_period_in_top_page: "Anzahl der Themen, die in der mit \"Mehr zeigen\" erweiterten Top-Themenübersicht angezeigt werden."
|
||||||
redirect_users_to_top_page: "Verweise neue und länger abwesende Nutzer automatisch zur Top Übersichtsseite"
|
redirect_users_to_top_page: "Verweise neue und länger abwesende Nutzer automatisch zur Top Übersichtsseite"
|
||||||
|
|
|
@ -877,9 +877,6 @@ en:
|
||||||
suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post."
|
suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post."
|
||||||
suppress_reply_when_quoting: "Don't show the expandable in-reply-to on a post when post quotes reply."
|
suppress_reply_when_quoting: "Don't show the expandable in-reply-to on a post when post quotes reply."
|
||||||
max_reply_history: "Maximum number of replies to expand when expanding in-reply-to"
|
max_reply_history: "Maximum number of replies to expand when expanding in-reply-to"
|
||||||
|
|
||||||
experimental_reply_expansion: "Hide intermediate replies when expanding a reply to (experimental)"
|
|
||||||
|
|
||||||
topics_per_period_in_top_summary: "Number of top topics shown in the default top topics summary."
|
topics_per_period_in_top_summary: "Number of top topics shown in the default top topics summary."
|
||||||
topics_per_period_in_top_page: "Number of top topics shown on the expanded 'Show More' top topics."
|
topics_per_period_in_top_page: "Number of top topics shown on the expanded 'Show More' top topics."
|
||||||
redirect_users_to_top_page: "Automatically redirect new and long absent users to the top page."
|
redirect_users_to_top_page: "Automatically redirect new and long absent users to the top page."
|
||||||
|
|
|
@ -773,7 +773,6 @@ es:
|
||||||
suppress_reply_directly_above: "No mostrar el en-respuesta-a desplegable en un post cuando solo hay una sola respuesta justo encima del post."
|
suppress_reply_directly_above: "No mostrar el en-respuesta-a desplegable en un post cuando solo hay una sola respuesta justo encima del post."
|
||||||
suppress_reply_when_quoting: "No mostrar el desplegable en-respuesta-a en un post cuando el post cite la respuesta."
|
suppress_reply_when_quoting: "No mostrar el desplegable en-respuesta-a en un post cuando el post cite la respuesta."
|
||||||
max_reply_history: "Número máximo de respuestas a mostrar al expandir en-respuesta-a"
|
max_reply_history: "Número máximo de respuestas a mostrar al expandir en-respuesta-a"
|
||||||
experimental_reply_expansion: "Ocultar respuestas intermedias cuando se expande una respuesta (experimental)"
|
|
||||||
topics_per_period_in_top_summary: "Número de mejores temas mostrados en el resumen de mejores temas."
|
topics_per_period_in_top_summary: "Número de mejores temas mostrados en el resumen de mejores temas."
|
||||||
topics_per_period_in_top_page: "Número de mejores temas mostrados en la vista expandida al clicar en 'ver más'."
|
topics_per_period_in_top_page: "Número de mejores temas mostrados en la vista expandida al clicar en 'ver más'."
|
||||||
redirect_users_to_top_page: "Redirigir automáticamente a los nuevos usuarios y a los ausentes de larga duración a la página de mejores temas."
|
redirect_users_to_top_page: "Redirigir automáticamente a los nuevos usuarios y a los ausentes de larga duración a la página de mejores temas."
|
||||||
|
|
|
@ -699,7 +699,6 @@ fa_IR:
|
||||||
suppress_reply_directly_above: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی فقط یک پاسخ بالای این نوشته است."
|
suppress_reply_directly_above: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی فقط یک پاسخ بالای این نوشته است."
|
||||||
suppress_reply_when_quoting: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی به یک نوشته پاسخ داده می شود."
|
suppress_reply_when_quoting: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی به یک نوشته پاسخ داده می شود."
|
||||||
max_reply_history: "حداکثر تعداد پاسخ ها به توسعه زمان گسترش in-reply-to"
|
max_reply_history: "حداکثر تعداد پاسخ ها به توسعه زمان گسترش in-reply-to"
|
||||||
experimental_reply_expansion: "پاسخ های میانی را مخفی کن زمان توسعه یک پاسخ به (آزمایشی) "
|
|
||||||
topics_per_period_in_top_summary: "تعداد بهتریت جستارهای نشان داده شده در بخش پیش فرض خلاصه بهترین جستارها."
|
topics_per_period_in_top_summary: "تعداد بهتریت جستارهای نشان داده شده در بخش پیش فرض خلاصه بهترین جستارها."
|
||||||
topics_per_period_in_top_page: "تعداد جستارهای خوب نشان داده شود در بخش گسترش یافته \" بیشتر نشان بده\" بهترین جستارها. "
|
topics_per_period_in_top_page: "تعداد جستارهای خوب نشان داده شود در بخش گسترش یافته \" بیشتر نشان بده\" بهترین جستارها. "
|
||||||
redirect_users_to_top_page: "بطور خودکار کاربران جدید و کاربران غایب را به بهترین صفحه هدایت کن."
|
redirect_users_to_top_page: "بطور خودکار کاربران جدید و کاربران غایب را به بهترین صفحه هدایت کن."
|
||||||
|
|
|
@ -781,7 +781,6 @@ fi:
|
||||||
suppress_reply_directly_above: "Älä näytä vastauksena-painiketta viestin yläreunassa, jos viestissä on vastattu vain edelliseen viestiin."
|
suppress_reply_directly_above: "Älä näytä vastauksena-painiketta viestin yläreunassa, jos viestissä on vastattu vain edelliseen viestiin."
|
||||||
suppress_reply_when_quoting: "Älä näytä vastauksena-painiketta viestin yläreunassa, kun viestissä on lainaus."
|
suppress_reply_when_quoting: "Älä näytä vastauksena-painiketta viestin yläreunassa, kun viestissä on lainaus."
|
||||||
max_reply_history: "Maksimimäärä vastauksia, jotka avataan klikattaessa 'vastauksena' painiketta"
|
max_reply_history: "Maksimimäärä vastauksia, jotka avataan klikattaessa 'vastauksena' painiketta"
|
||||||
experimental_reply_expansion: "Piilota välilliset vastaukset, kun 'vastauksena' avataan (kokeellinen)"
|
|
||||||
topics_per_period_in_top_summary: "Ketjujen lukumäärä, joka näytetään oletuksena Huiput-listauksissa."
|
topics_per_period_in_top_summary: "Ketjujen lukumäärä, joka näytetään oletuksena Huiput-listauksissa."
|
||||||
topics_per_period_in_top_page: "Ketjujen lukumäärä, joka näytetään laajennetussa Huiput-listauksessa."
|
topics_per_period_in_top_page: "Ketjujen lukumäärä, joka näytetään laajennetussa Huiput-listauksessa."
|
||||||
redirect_users_to_top_page: "Ohjaa uudet ja kauan poissa olleet käyttäjät automaattisesti huiput-sivulle."
|
redirect_users_to_top_page: "Ohjaa uudet ja kauan poissa olleet käyttäjät automaattisesti huiput-sivulle."
|
||||||
|
|
|
@ -775,7 +775,6 @@ fr:
|
||||||
suppress_reply_directly_above: "Ne pas afficher 'en réponse à' sur un message quand la seule réponse est juste en dessus de ce dernier."
|
suppress_reply_directly_above: "Ne pas afficher 'en réponse à' sur un message quand la seule réponse est juste en dessus de ce dernier."
|
||||||
suppress_reply_when_quoting: "Ne pas affiché le panneau \"En réponse à\" sur un message qui répond à une citation."
|
suppress_reply_when_quoting: "Ne pas affiché le panneau \"En réponse à\" sur un message qui répond à une citation."
|
||||||
max_reply_history: "Nombre maximum de réponses à développer lors du développement d'une \"réponse à\""
|
max_reply_history: "Nombre maximum de réponses à développer lors du développement d'une \"réponse à\""
|
||||||
experimental_reply_expansion: "Masquer les réponses intermédiaires lors de l'ouverture d'une répondre à (expérimental)"
|
|
||||||
topics_per_period_in_top_summary: "Nombre de meilleurs sujets affichés dans le résumé par défaut des meilleurs sujets."
|
topics_per_period_in_top_summary: "Nombre de meilleurs sujets affichés dans le résumé par défaut des meilleurs sujets."
|
||||||
topics_per_period_in_top_page: "Nombre de meilleurs sujets affichés lorsqu'on sélectionne \"Voir plus\" des meilleurs sujets."
|
topics_per_period_in_top_page: "Nombre de meilleurs sujets affichés lorsqu'on sélectionne \"Voir plus\" des meilleurs sujets."
|
||||||
redirect_users_to_top_page: "Rediriger automatiquement les nouveaux utilisateurs et les longues absences sur la page Top."
|
redirect_users_to_top_page: "Rediriger automatiquement les nouveaux utilisateurs et les longues absences sur la page Top."
|
||||||
|
|
|
@ -727,7 +727,6 @@ he:
|
||||||
suppress_reply_directly_above: "אל תציגו את את אפשרות ההרחבה \"בתגובה ל..\" לפרסום כאשר יש רק תגובה אחת ישירה מעל לפרסום זה."
|
suppress_reply_directly_above: "אל תציגו את את אפשרות ההרחבה \"בתגובה ל..\" לפרסום כאשר יש רק תגובה אחת ישירה מעל לפרסום זה."
|
||||||
suppress_reply_when_quoting: "אל תציגו את הפרסום המקורי בפרסומים שמצטטים תגובות"
|
suppress_reply_when_quoting: "אל תציגו את הפרסום המקורי בפרסומים שמצטטים תגובות"
|
||||||
max_reply_history: "מספר התגובות המקסימלי להרחבה כאשר מרחיבים \"בתגובה ל\""
|
max_reply_history: "מספר התגובות המקסימלי להרחבה כאשר מרחיבים \"בתגובה ל\""
|
||||||
experimental_reply_expansion: "החבא תגובות ביניים כאשר מרחיבים תגובה (ניסיוני)"
|
|
||||||
topics_per_period_in_top_summary: "מספר הנושאים המוצגים בבריכת המחדל של סיכום הנושאים."
|
topics_per_period_in_top_summary: "מספר הנושאים המוצגים בבריכת המחדל של סיכום הנושאים."
|
||||||
topics_per_period_in_top_page: "מספר הנושאים הראשונים המוצגים בתצוגה המורחבת של \"הצג עוד\"."
|
topics_per_period_in_top_page: "מספר הנושאים הראשונים המוצגים בתצוגה המורחבת של \"הצג עוד\"."
|
||||||
redirect_users_to_top_page: "כוון באופן אוטומטי משתמשים חדשים וכאלה שנעדרו במשך זמן לראש העמוד."
|
redirect_users_to_top_page: "כוון באופן אוטומטי משתמשים חדשים וכאלה שנעדרו במשך זמן לראש העמוד."
|
||||||
|
|
|
@ -728,7 +728,6 @@ it:
|
||||||
suppress_reply_directly_above: "Non mostrare in-risposta-a espandibile in un messaggio quando c'è una sola risposta sopra quel messaggio. "
|
suppress_reply_directly_above: "Non mostrare in-risposta-a espandibile in un messaggio quando c'è una sola risposta sopra quel messaggio. "
|
||||||
suppress_reply_when_quoting: "Non mostrare in-risposta-a espandibile in un messaggio quando il messaggio include la citazione."
|
suppress_reply_when_quoting: "Non mostrare in-risposta-a espandibile in un messaggio quando il messaggio include la citazione."
|
||||||
max_reply_history: "Numero massimo di risposte da espandere quando si espande in-risposta-a"
|
max_reply_history: "Numero massimo di risposte da espandere quando si espande in-risposta-a"
|
||||||
experimental_reply_expansion: "Nascondi le risposte intermedie quando si espande una risposta (sperimentale)"
|
|
||||||
topics_per_period_in_top_summary: "Numero di argomenti di punta mostrati nel riepilogo di default."
|
topics_per_period_in_top_summary: "Numero di argomenti di punta mostrati nel riepilogo di default."
|
||||||
topics_per_period_in_top_page: "Numero di argomenti di punta mostrati nella vista espansa 'Mostra Altro'"
|
topics_per_period_in_top_page: "Numero di argomenti di punta mostrati nella vista espansa 'Mostra Altro'"
|
||||||
redirect_users_to_top_page: "Redirigi automaticamente i nuovi utenti e quelli assenti da tempo sulla pagina degli argomenti di punta."
|
redirect_users_to_top_page: "Redirigi automaticamente i nuovi utenti e quelli assenti da tempo sulla pagina degli argomenti di punta."
|
||||||
|
|
|
@ -624,7 +624,6 @@ ja:
|
||||||
suppress_reply_directly_above: "ポストに回答が1つしかない場合、ポストのin-reply-toを表示しない"
|
suppress_reply_directly_above: "ポストに回答が1つしかない場合、ポストのin-reply-toを表示しない"
|
||||||
suppress_reply_when_quoting: "ポストが引用返信だった場合、ポストのin-reply-toを表示しない"
|
suppress_reply_when_quoting: "ポストが引用返信だった場合、ポストのin-reply-toを表示しない"
|
||||||
max_reply_history: "回答のin-reply-toを展開する最大数"
|
max_reply_history: "回答のin-reply-toを展開する最大数"
|
||||||
experimental_reply_expansion: "回答を展開するときに、その間にある回答を非表示にする(実験)"
|
|
||||||
topics_per_period_in_top_summary: "デフォルトのトピックサマリに表示されるトップトピックの数"
|
topics_per_period_in_top_summary: "デフォルトのトピックサマリに表示されるトップトピックの数"
|
||||||
topics_per_period_in_top_page: "'もっと見る'を展開したときに表示するトップトピックの数"
|
topics_per_period_in_top_page: "'もっと見る'を展開したときに表示するトップトピックの数"
|
||||||
redirect_users_to_top_page: "新規ユーザーと長く不在のユーザーをトップページに自動的にリダイレクトさせる"
|
redirect_users_to_top_page: "新規ユーザーと長く不在のユーザーをトップページに自動的にリダイレクトさせる"
|
||||||
|
|
|
@ -643,7 +643,6 @@ ko:
|
||||||
suppress_reply_directly_above: "단 하나의 댓글 위의 글이 하나 있는 상황에서 '~에 대한 댓글'을 보여주지 않음."
|
suppress_reply_directly_above: "단 하나의 댓글 위의 글이 하나 있는 상황에서 '~에 대한 댓글'을 보여주지 않음."
|
||||||
suppress_reply_when_quoting: "글안에 답글이 인용될 때 in-reply-to를 보여주지 않습니다."
|
suppress_reply_when_quoting: "글안에 답글이 인용될 때 in-reply-to를 보여주지 않습니다."
|
||||||
max_reply_history: "덧글 확장해서 보여지는 최대 갯수"
|
max_reply_history: "덧글 확장해서 보여지는 최대 갯수"
|
||||||
experimental_reply_expansion: "답글을 펼쳤을 때 중간 답글을 숨깁니다(실험 중)"
|
|
||||||
topics_per_period_in_top_summary: "인기 글타래 요약에 기본으로 보여질 글타래 수"
|
topics_per_period_in_top_summary: "인기 글타래 요약에 기본으로 보여질 글타래 수"
|
||||||
topics_per_period_in_top_page: "인기 글타래에서 '더 보기'를 요청할 시 보여질 글타래 수"
|
topics_per_period_in_top_page: "인기 글타래에서 '더 보기'를 요청할 시 보여질 글타래 수"
|
||||||
redirect_users_to_top_page: "자동으로 신규 사용자와 오래간만에 들어온 사용자를 탑 페이지로 리다이렉트 시킴"
|
redirect_users_to_top_page: "자동으로 신규 사용자와 오래간만에 들어온 사용자를 탑 페이지로 리다이렉트 시킴"
|
||||||
|
|
|
@ -746,7 +746,6 @@ nl:
|
||||||
suppress_reply_directly_above: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er maar één reactie direct boven staat."
|
suppress_reply_directly_above: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er maar één reactie direct boven staat."
|
||||||
suppress_reply_when_quoting: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er er gequoteerd is."
|
suppress_reply_when_quoting: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er er gequoteerd is."
|
||||||
max_reply_history: "Maximaal aantal uit te klappen antwoorden als een 'in antwoord op' uitgeklapt wordt."
|
max_reply_history: "Maximaal aantal uit te klappen antwoorden als een 'in antwoord op' uitgeklapt wordt."
|
||||||
experimental_reply_expansion: "Verberg tussenliggende antwoorden als een antwoord uitgeklapt wordt (experimenteel)"
|
|
||||||
topics_per_period_in_top_summary: "Aantal topics in het top-topics overzicht"
|
topics_per_period_in_top_summary: "Aantal topics in het top-topics overzicht"
|
||||||
topics_per_period_in_top_page: "Aantal topics in het uitgeklapte ´Meer...´ top-topics overzicht"
|
topics_per_period_in_top_page: "Aantal topics in het uitgeklapte ´Meer...´ top-topics overzicht"
|
||||||
redirect_users_to_top_page: "Stuur nieuwe en lang-niet-geziene gebruikers door naar de top-pagina"
|
redirect_users_to_top_page: "Stuur nieuwe en lang-niet-geziene gebruikers door naar de top-pagina"
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue