Merge branch 'master' of github.com:discourse/discourse

This commit is contained in:
Sam 2016-02-24 13:53:43 +11:00
commit 3c072cdfc9
193 changed files with 9727 additions and 5457 deletions

View File

@ -90,6 +90,7 @@
"no-undef": 2,
"no-unused-vars": 2,
"no-with": 2,
"no-this-before-super": 2,
"semi": 2,
"strict": 0,
"valid-typeof": 2,

View File

@ -152,21 +152,20 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, {
})
});
function proxyDep(propName, moduleFunc, msg) {
if (Discourse.hasOwnProperty(propName)) { return; }
Object.defineProperty(Discourse, propName, {
get: function() {
msg = msg || "import the module";
Ember.warn("DEPRECATION: `Discourse." + propName + "` is deprecated, " + msg + ".");
return moduleFunc();
}
});
function RemovedObject(name) {
this._removedName = name;
}
proxyDep('computed', function() { return require('discourse/lib/computed'); });
proxyDep('Formatter', function() { return require('discourse/lib/formatter'); });
proxyDep('PageTracker', function() { return require('discourse/lib/page-tracker').default; });
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; });
proxyDep('View', function() { return Ember.View; }, "Use `Ember.View` instead");
function methodMissing() {
console.warn("The " + this._removedName + " object has been removed from Discourse " +
"and your plugin needs to be updated.");
};
Discourse.RemovedObject = RemovedObject;
['reopen', 'registerButton', 'on', 'off'].forEach(function(m) { RemovedObject.prototype[m] = methodMissing; });
['discourse/views/post', 'discourse/components/post-menu'].forEach(function(moduleName) {
define(moduleName, [], function() { return new RemovedObject(moduleName); });
});

View File

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

View File

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

View File

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

View File

@ -12,8 +12,8 @@ export default Ember.Component.extend({
return !c.get('parentCategory');
}),
hidden: function(){
return Discourse.Mobile.mobileView && !this.get('category');
hidden: function() {
return this.site.mobileView && !this.get('category');
}.property('category'),
firstCategory: function() {

View File

@ -18,7 +18,7 @@ export default Ember.Component.extend({
@on('init')
_setupPreview() {
const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true'));
const val = (this.site.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true'));
this.set('showPreview', val === 'true');
},
@ -91,6 +91,8 @@ export default Ember.Component.extend({
_syncEditorAndPreviewScroll() {
const $input = this.$('.d-editor-input');
if (!$input) { return; }
const $preview = this.$('.d-editor-preview');
if ($input.scrollTop() === 0) {
@ -216,7 +218,7 @@ export default Ember.Component.extend({
}
});
if (Discourse.Mobile.mobileView) {
if (this.site.mobileView) {
this.$(".mobile-file-upload").on("click.uploader", function () {
// redirect the click on the hidden file input
$("#mobile-uploader").click();

View File

@ -25,156 +25,164 @@ const OP = {
const _createCallbacks = [];
function Toolbar() {
this.shortcuts = {};
class Toolbar {
this.groups = [
{group: 'fontStyles', buttons: []},
{group: 'insertions', buttons: []},
{group: 'extras', buttons: []}
];
constructor(site) {
this.shortcuts = {};
this.addButton({
id: 'bold',
group: 'fontStyles',
shortcut: 'B',
perform: e => e.applySurround('**', '**', 'bold_text')
});
this.addButton({
id: 'italic',
group: 'fontStyles',
shortcut: 'I',
perform: e => e.applySurround('_', '_', 'italic_text')
});
this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'});
this.addButton({
id: 'quote',
group: 'insertions',
icon: 'quote-right',
shortcut: 'Shift+9',
perform: e => e.applySurround('> ', '', 'code_text')
});
this.addButton({
id: 'code',
group: 'insertions',
shortcut: 'Shift+C',
perform(e) {
if (e.selected.value.indexOf("\n") !== -1) {
e.applySurround(' ', '', 'code_text');
} else {
e.applySurround('`', '`', 'code_text');
}
},
});
this.addButton({
id: 'bullet',
group: 'extras',
icon: 'list-ul',
shortcut: 'Shift+8',
title: 'composer.ulist_title',
perform: e => e.applyList('* ', 'list_item')
});
this.addButton({
id: 'list',
group: 'extras',
icon: 'list-ol',
shortcut: 'Shift+7',
title: 'composer.olist_title',
perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item')
});
this.addButton({
id: 'heading',
group: 'extras',
icon: 'font',
shortcut: 'Alt+1',
perform: e => e.applyList('## ', 'heading_text')
});
this.addButton({
id: 'rule',
group: 'extras',
icon: 'minus',
shortcut: 'Alt+R',
title: 'composer.hr_title',
perform: e => e.addText("\n\n----------\n")
});
if (Discourse.Mobile.mobileView) {
this.groups.push({group: 'mobileExtras', buttons: []});
this.groups = [
{group: 'fontStyles', buttons: []},
{group: 'insertions', buttons: []},
{group: 'extras', buttons: []}
];
this.addButton({
id: 'preview',
group: 'mobileExtras',
icon: 'television',
title: 'composer.hr_preview',
perform: e => e.preview()
id: 'bold',
group: 'fontStyles',
shortcut: 'B',
perform: e => e.applySurround('**', '**', 'bold_text')
});
}
this.groups[this.groups.length-1].lastGroup = true;
};
this.addButton({
id: 'italic',
group: 'fontStyles',
shortcut: 'I',
perform: e => e.applySurround('_', '_', 'italic_text')
});
Toolbar.prototype.addButton = function(button) {
const g = this.groups.findProperty('group', button.group);
if (!g) {
throw `Couldn't find toolbar group ${button.group}`;
}
this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'});
const createdButton = {
id: button.id,
className: button.className || button.id,
icon: button.icon || button.id,
action: button.action || 'toolbarButton',
perform: button.perform || Ember.K
};
this.addButton({
id: 'quote',
group: 'insertions',
icon: 'quote-right',
shortcut: 'Shift+9',
perform: e => e.applySurround('> ', '', 'code_text')
});
if (button.sendAction) {
createdButton.sendAction = button.sendAction;
}
this.addButton({
id: 'code',
group: 'insertions',
shortcut: 'Shift+C',
perform(e) {
if (e.selected.value.indexOf("\n") !== -1) {
e.applySurround(' ', '', 'code_text');
} else {
e.applySurround('`', '`', 'code_text');
}
},
});
const title = I18n.t(button.title || `composer.${button.id}_title`);
if (button.shortcut) {
const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = mac ? 'Meta' : 'Ctrl';
var shortcutTitle = `${mod}+${button.shortcut}`;
this.addButton({
id: 'bullet',
group: 'extras',
icon: 'list-ul',
shortcut: 'Shift+8',
title: 'composer.ulist_title',
perform: e => e.applyList('* ', 'list_item')
});
// Mac users are used to glyphs for shortcut keys
if (mac) {
shortcutTitle = shortcutTitle
.replace('Shift', "\u21E7")
.replace('Meta', "\u2318")
.replace('Alt', "\u2325")
.replace(/\+/g, '');
} else {
shortcutTitle = shortcutTitle
.replace('Shift', I18n.t('shortcut_modifier_key.shift'))
.replace('Ctrl', I18n.t('shortcut_modifier_key.ctrl'))
.replace('Alt', I18n.t('shortcut_modifier_key.alt'));
this.addButton({
id: 'list',
group: 'extras',
icon: 'list-ol',
shortcut: 'Shift+7',
title: 'composer.olist_title',
perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item')
});
this.addButton({
id: 'heading',
group: 'extras',
icon: 'font',
shortcut: 'Alt+1',
perform: e => e.applyList('## ', 'heading_text')
});
this.addButton({
id: 'rule',
group: 'extras',
icon: 'minus',
shortcut: 'Alt+R',
title: 'composer.hr_title',
perform: e => e.addText("\n\n----------\n")
});
if (site.mobileView) {
this.groups.push({group: 'mobileExtras', buttons: []});
this.addButton({
id: 'preview',
group: 'mobileExtras',
icon: 'television',
title: 'composer.hr_preview',
perform: e => e.preview()
});
}
createdButton.title = `${title} (${shortcutTitle})`;
this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton;
} else {
createdButton.title = title;
this.groups[this.groups.length-1].lastGroup = true;
}
if (button.unshift) {
g.buttons.unshift(createdButton);
} else {
g.buttons.push(createdButton);
addButton(button) {
const g = this.groups.findProperty('group', button.group);
if (!g) {
throw `Couldn't find toolbar group ${button.group}`;
}
const createdButton = {
id: button.id,
className: button.className || button.id,
icon: button.icon || button.id,
action: button.action || 'toolbarButton',
perform: button.perform || Ember.K
};
if (button.sendAction) {
createdButton.sendAction = button.sendAction;
}
const title = I18n.t(button.title || `composer.${button.id}_title`);
if (button.shortcut) {
const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = mac ? 'Meta' : 'Ctrl';
var shortcutTitle = `${mod}+${button.shortcut}`;
// Mac users are used to glyphs for shortcut keys
if (mac) {
shortcutTitle = shortcutTitle
.replace('Shift', "\u21E7")
.replace('Meta', "\u2318")
.replace('Alt', "\u2325")
.replace(/\+/g, '');
} else {
shortcutTitle = shortcutTitle
.replace('Shift', I18n.t('shortcut_modifier_key.shift'))
.replace('Ctrl', I18n.t('shortcut_modifier_key.ctrl'))
.replace('Alt', I18n.t('shortcut_modifier_key.alt'));
}
createdButton.title = `${title} (${shortcutTitle})`;
this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton;
} else {
createdButton.title = title;
}
if (button.unshift) {
g.buttons.unshift(createdButton);
} else {
g.buttons.push(createdButton);
}
}
};
}
export function addToolbarCallback(func) {
_createCallbacks.push(func);
}
export function onToolbarCreate(func) {
_createCallbacks.push(func);
console.warn('`onToolbarCreate` is deprecated, use the plugin api instead.');
addToolbarCallback(func);
};
export default Ember.Component.extend({
@ -237,7 +245,7 @@ export default Ember.Component.extend({
@computed
toolbar() {
const toolbar = new Toolbar();
const toolbar = new Toolbar(this.site);
_createCallbacks.forEach(cb => cb(toolbar));
this.sendAction('extraButtons', toolbar);
return toolbar;

View File

@ -10,17 +10,17 @@ export default Ember.Component.extend({
@computed()
showKeyboardShortcuts() {
return !Discourse.Mobile.mobileView && !this.capabilities.touch;
return !this.site.mobileView && !this.capabilities.touch;
},
@computed()
showMobileToggle() {
return Discourse.Mobile.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch);
return this.site.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch);
},
@computed()
mobileViewLinkTextKey() {
return Discourse.Mobile.mobileView ? "desktop_view" : "mobile_view";
return this.site.mobileView ? "desktop_view" : "mobile_view";
},
@computed()
@ -68,7 +68,7 @@ export default Ember.Component.extend({
this.sendAction('showKeyboardAction');
},
toggleMobileView() {
Discourse.Mobile.toggleMobileView();
this.site.toggleMobileView();
}
}
});

View File

@ -17,7 +17,7 @@ export default Ember.Component.extend({
if (this.siteSettings.login_required && !this.currentUser) {
this.sendAction('loginAction');
} else {
if (Discourse.Mobile.mobileView && this.get('mobileAction')) {
if (this.site.mobileView && this.get('mobileAction')) {
this.sendAction('mobileAction');
return;
}

View File

@ -14,11 +14,11 @@ export default Ember.Component.extend({
}.property('targetUrl'),
showSmallLogo: function() {
return !Discourse.Mobile.mobileView && this.get("minimized");
return !this.site.mobileView && this.get("minimized");
}.property("minimized"),
showMobileLogo: function() {
return Discourse.Mobile.mobileView && !Ember.isBlank(this.get('mobileBigLogoUrl'));
return this.site.mobileView && !Ember.isBlank(this.get('mobileBigLogoUrl'));
}.property(),
smallLogoUrl: setting('logo_small_url'),

View File

@ -120,17 +120,17 @@ export default Ember.Component.extend({
@computed()
showKeyboardShortcuts() {
return !Discourse.Mobile.mobileView && !this.capabilities.touch;
return !this.site.mobileView && !this.capabilities.touch;
},
@computed()
showMobileToggle() {
return Discourse.Mobile.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch);
return this.site.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch);
},
@computed()
mobileViewLinkTextKey() {
return Discourse.Mobile.mobileView ? "desktop_view" : "mobile_view";
return this.site.mobileView ? "desktop_view" : "mobile_view";
},
@computed()

View File

@ -0,0 +1,74 @@
import { diff, patch } from 'virtual-dom';
import { WidgetClickHook } from 'discourse/widgets/click-hook';
import { renderedKey } from 'discourse/widgets/widget';
const _cleanCallbacks = {};
export function addWidgetCleanCallback(widgetName, fn) {
_cleanCallbacks[widgetName] = _cleanCallbacks[widgetName] || [];
_cleanCallbacks[widgetName].push(fn);
}
export default Ember.Component.extend({
_tree: null,
_rootNode: null,
_timeout: null,
_widgetClass: null,
_afterRender: null,
init() {
this._super();
this._widgetClass = this.container.lookupFactory(`widget:${this.get('widget')}`);
this._connected = [];
},
didInsertElement() {
WidgetClickHook.setupDocumentCallback();
this._rootNode = document.createElement('div');
this.element.appendChild(this._rootNode);
this._timeout = Ember.run.scheduleOnce('render', this, this.rerenderWidget);
},
willClearRender() {
const callbacks = _cleanCallbacks[this.get('widget')];
if (callbacks) {
callbacks.forEach(cb => cb());
}
this._connected.forEach(v => v.destroy());
this._connected.length = 0;
},
willDestroyElement() {
Ember.run.cancel(this._timeout);
},
queueRerender(callback) {
if (callback && !this._afterRender) {
this._afterRender = callback;
}
Ember.run.scheduleOnce('render', this, this.rerenderWidget);
},
rerenderWidget() {
Ember.run.cancel(this._timeout);
if (this._rootNode) {
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;
if (this._afterRender) {
this._afterRender();
this._afterRender = null;
}
renderedKey('*');
}
}
});

View File

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

View File

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

View File

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

View File

@ -1,77 +1,2 @@
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;
const removed = new Discourse.RemovedObject('discourse/components/poster-name');
export default removed;

View File

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

View File

@ -0,0 +1,176 @@
import DiscourseURL from 'discourse/lib/url';
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',
'gaps',
'selectedQuery',
'selectedPostsCount',
'searchService');
}).volatile(),
scrolled() {
if (this.isDestroyed || this.isDestroying) { return; }
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.$('.onscreen-post');
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 = cb => this.queueRerender(cb);
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(() => {
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);
this.appEvents.on('post-stream:refresh', debouncedScroll);
$(document).bind('touchmove.post-stream', debouncedScroll);
$(window).bind('scroll.post-stream', debouncedScroll);
this._scrollTriggered();
this.appEvents.on('post-stream:posted', staged => {
const disableJumpReply = this.currentUser.get('disable_jump_reply');
this.queueRerender(() => {
if (staged && !disableJumpReply) {
const postNumber = staged.get('post_number');
DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true });
}
});
});
this.$().on('mouseenter.post-stream', 'button.widget-button', e => {
$('button.widget-button').removeClass('d-hover');
$(e.target).addClass('d-hover');
});
this.$().on('mouseleave.post-stream', 'button.widget-button', () => {
$('button.widget-button').removeClass('d-hover');
});
this.appEvents.on('post-stream:refresh', args => {
if (args) {
if (args.id) {
keyDirty(`post-${args.id}`);
} else if (args.force) {
keyDirty(`*`);
}
}
this.queueRerender();
});
},
willDestroyElement() {
this._super();
$(document).unbind('touchmove.post-stream');
$(window).unbind('scroll.post-stream');
this.appEvents.off('post-stream:refresh');
this.$().off('mouseenter.post-stream');
this.$().off('mouseleave.post-stream');
this.appEvents.off('post-stream:refresh');
this.appEvents.off('post-stream:posted');
}
});

View File

@ -1,33 +1,17 @@
import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
import computed from 'ember-addons/ember-computed-decorators';
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 function actionDescriptionHtml(actionCode, createdAt, username) {
const dt = new Date(createdAt);
const when = autoUpdatingRelativeAge(dt, { format: 'medium-with-ago' });
const who = username ? `<a class="mention" href="/users/${username}">@${username}</a>` : "";
return I18n.t(`action_codes.${actionCode}`, { who, when }).htmlSafe();
}
export function actionDescription(actionCode, createdAt, username) {
return function() {
const ac = this.get(actionCode);
if (ac) {
const dt = new Date(this.get(createdAt));
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();
return actionDescriptionHtml(ac, this.get(createdAt), this.get(username));
}
}.property(actionCode, createdAt);
}
@ -38,11 +22,6 @@ export default Ember.Component.extend({
description: actionDescription('actionCode', 'post.created_at', 'post.action_code_who'),
@computed("actionCode")
icon(actionCode) {
return icons[actionCode] || 'exclamation';
},
actions: {
edit() {
this.sendAction('editPost', this.get('post'));

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export default Ember.Component.extend({
}.property(),
skipHeader: function() {
return Discourse.Mobile.mobileView;
return this.site.mobileView;
}.property(),
showLikes: function(){

View File

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

View File

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

View File

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

View File

@ -263,7 +263,6 @@ export default Ember.Controller.extend({
}
var staged = false;
const disableJumpReply = Discourse.User.currentProp('disable_jump_reply');
// TODO: This should not happen in model
const imageSizes = {};
@ -281,6 +280,7 @@ export default Ember.Controller.extend({
self.send('postWasEnqueued', result.responseJson);
self.destroyDraft();
self.close();
self.appEvents.trigger('post-stream:refresh');
return result;
}
@ -288,7 +288,15 @@ export default Ember.Controller.extend({
if (result.responseJson.action === "create_post" || self.get('replyAsNewTopicDraft')) {
self.destroyDraft();
}
if (self.get('model.action') === 'edit') {
self.appEvents.trigger('post-stream:refresh', { id: parseInt(result.responseJson.id) });
} else {
self.appEvents.trigger('post-stream:refresh');
}
if (result.responseJson.action === "create_post") {
self.appEvents.trigger('post:highlight', result.payload.post_number);
}
self.close();
const currentUser = Discourse.User.current();
@ -298,14 +306,14 @@ export default Ember.Controller.extend({
currentUser.set('reply_count', currentUser.get('reply_count') + 1);
}
// TODO disableJumpReply is super crude, it needs to provide some sort
// of notification to the end user
const disableJumpReply = Discourse.User.currentProp('disable_jump_reply');
if (!composer.get('replyingToTopic') || !disableJumpReply) {
const post = result.target;
if (post && !staged) {
DiscourseURL.routeTo(post.get('url'));
}
}
}).catch(function(error) {
composer.set('disableDrafts', false);
self.appEvents.one('composer:opened', () => bootbox.alert(error));
@ -316,18 +324,10 @@ export default Ember.Controller.extend({
staged = composer.get('stagedPost');
}
Em.run.schedule('afterRender', function() {
if (staged && !disableJumpReply) {
const postNumber = staged.get('post_number');
DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true });
self.appEvents.trigger('post:highlight', postNumber);
}
});
this.appEvents.trigger('post-stream:posted', staged);
this.messageBus.pause();
promise.finally(function(){
self.messageBus.resume();
});
promise.finally(() => this.messageBus.resume());
return promise;
},
@ -587,14 +587,6 @@ export default Ember.Controller.extend({
$('.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() {
return this.get("model.action") === "edit" && Discourse.User.current().get("can_edit");
}.property("model.action"),

View File

@ -89,7 +89,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
},
createFlag(opts) {
const self = this;
let postAction; // an instance of ActionSummary
if (!this.get('flagTopic')) {
@ -103,13 +102,14 @@ export default Ember.Controller.extend(ModalFunctionality, {
this.send('hideModal');
postAction.act(this.get('model'), params).then(function() {
self.send('closeModal');
postAction.act(this.get('model'), params).then(() => {
this.send('closeModal');
if (params.message) {
self.set('message', '');
this.set('message', '');
}
}, function(errors) {
self.send('closeModal');
this.appEvents.trigger('post-stream:refresh', { id: this.get('model.id') });
}).catch(errors => {
this.send('closeModal');
if (errors && errors.responseText) {
bootbox.alert($.parseJSON(errors.responseText).errors);
} else {

View File

@ -10,7 +10,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
revisionsTextKey: "post.revisions.controls.comparing_previous_to_current_out_of_total",
_changeViewModeOnMobile: function() {
if (Discourse.Mobile.mobileView) { this.set("viewMode", "inline"); }
if (this.site.mobileView) { this.set("viewMode", "inline"); }
}.on("init"),
refresh(postId, postVersion) {

View File

@ -61,8 +61,10 @@ export default Ember.Controller.extend({
// containing a single invisible character
markerElement.appendChild(document.createTextNode("\ufeff"));
const isMobileDevice = this.site.isMobileDevice;
// collapse the range at the beginning/end of the selection
range.collapse(!Discourse.Mobile.isMobileDevice);
range.collapse(!isMobileDevice);
// and insert it at the start of our selection range
range.insertNode(markerElement);
@ -83,7 +85,7 @@ export default Ember.Controller.extend({
let topOff = markerOffset.top;
let leftOff = markerOffset.left;
if (Discourse.Mobile.isMobileDevice) {
if (isMobileDevice) {
topOff = topOff + 20;
leftOff = Math.min(leftOff + 10, $(window).width() - $quoteButton.outerWidth());
} else {

View File

@ -19,7 +19,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
loadedAllPosts: Em.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
enteredAt: null,
retrying: false,
firstPostExpanded: false,
adminMenuVisible: false,
showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'),
@ -100,7 +99,64 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
DiscourseURL.routeTo(url);
},
selectedQuery: function() {
return post => this.postSelected(post);
}.property(),
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');
const currentPostNumber = post.get('post_number');
this.set('model.currentPost', currentPostNumber);
this.send('postChangedRoute', currentPostNumber);
if (post.get('post_number') === 1) { return; }
if (firstLoadedPost && firstLoadedPost === post) {
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.get('canAppendMore')) {
postStream.appendMore().then(() => refresh());
// show loading stuff
refresh();
}
},
toggleSummary() {
return this.get('model.postStream').toggleSummary();
},
removeAllowedUser(user) {
return this.get('model.details').removeAllowedUser(user);
},
showTopicAdminMenu() {
this.set('adminMenuVisible', true);
},
@ -113,7 +169,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
this.deleteTopic();
},
archiveMessage() {
const topic = this.get('model');
topic.archiveMessage().then(()=>{
@ -176,8 +231,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
// Deleting the first post deletes the topic
if (post.get('post_number') === 1) {
this.deleteTopic();
return;
return this.deleteTopic();
} else if (!post.can_delete) {
// check if current user can delete post
return false;
@ -210,7 +264,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
]);
} else {
post.destroy(user).catch(function(error) {
return post.destroy(user).then(() => {
this.appEvents.trigger('post-stream:refresh');
}).catch(error => {
popupAjaxError(error);
post.undoDeleteState();
});
@ -245,14 +301,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
},
toggleBookmark(post) {
if (!Discourse.User.current()) {
if (!this.currentUser) {
alert(I18n.t("bookmarks.not_bookmarked"));
return;
}
if (post) {
return post.toggleBookmark().catch(popupAjaxError);
} else {
return this.get("model").toggleBookmark();
return this.get("model").toggleBookmark().then(changedIds => {
if (!changedIds) { return; }
changedIds.forEach(id => this.appEvents.trigger('post-stream:refresh', { id }));
});
}
},
@ -261,18 +320,20 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
},
selectAll() {
const posts = this.get('model.postStream.posts'),
selectedPosts = this.get('selectedPosts');
const posts = this.get('model.postStream.posts');
const selectedPosts = this.get('selectedPosts');
if (posts) {
selectedPosts.addObjects(posts);
}
this.set('allPostsSelected', true);
this.appEvents.trigger('post-stream:refresh', { force: true });
},
deselectAll() {
this.get('selectedPosts').clear();
this.get('selectedReplies').clear();
this.set('allPostsSelected', false);
this.appEvents.trigger('post-stream:refresh', { force: true });
},
toggleParticipant(user) {
@ -293,6 +354,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
toggleMultiSelect() {
this.toggleProperty('multiSelect');
this.appEvents.trigger('post-stream:refresh', { force: true });
},
finishedEditingTopic() {
@ -324,27 +386,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
},
deleteSelected() {
const self = this;
bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) {
bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), result => {
if (result) {
// If all posts are selected, it's the same thing as deleting the topic
if (self.get('allPostsSelected')) {
return self.deleteTopic();
if (this.get('allPostsSelected')) {
return this.deleteTopic();
}
const selectedPosts = self.get('selectedPosts'),
selectedReplies = self.get('selectedReplies'),
postStream = self.get('model.postStream'),
toRemove = [];
const selectedPosts = this.get('selectedPosts');
const selectedReplies = this.get('selectedReplies');
const postStream = this.get('model.postStream');
Discourse.Post.deleteMany(selectedPosts, selectedReplies);
postStream.get('posts').forEach(function (p) {
if (self.postSelected(p)) { toRemove.addObject(p); }
postStream.get('posts').forEach(p => {
if (this.postSelected(p)) {
p.set('deleted_at', new Date());
}
});
postStream.removePosts(toRemove);
self.send('toggleMultiSelect');
this.send('toggleMultiSelect');
}
});
},
@ -447,18 +508,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() {
const self = this;
self.set('retrying', true);
@ -470,22 +519,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
},
toggleWiki(post) {
post.updatePostField('wiki', !post.get('wiki'));
return post.updatePostField('wiki', !post.get('wiki'));
},
togglePostType(post) {
const regular = this.site.get('post_types.regular');
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) {
post.rebake();
return post.rebake();
},
unhidePost(post) {
post.unhide();
return post.unhide();
},
changePostOwner(post) {
@ -498,11 +547,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
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() {
if (!this.get('model.details.can_move_posts')) return false;
return this.get('selectedPostsCount') > 0;
@ -598,9 +642,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
// Unsubscribe before subscribing again
this.unsubscribe();
const self = this;
this.messageBus.subscribe("/topic/" + this.get('model.id'), function(data) {
const topic = self.get('model');
const refresh = (id) => this.appEvents.trigger('post-stream:refresh', { id });
this.messageBus.subscribe("/topic/" + this.get('model.id'), data => {
const topic = this.get('model');
if (data.notification_level_change) {
topic.set('details.notification_level', data.notification_level_change);
@ -608,26 +653,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return;
}
const postStream = self.get('model.postStream');
const postStream = this.get('model.postStream');
switch (data.type) {
case "revised":
case "acted":
case "rebaked": {
// 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;
}
case "deleted": {
postStream.triggerDeletedPost(data.id, data.post_number);
postStream.triggerDeletedPost(data.id, data.post_number).then(() => refresh(data.id));
return;
}
case "recovered": {
postStream.triggerRecoveredPost(data.id, data.post_number);
postStream.triggerRecoveredPost(data.id, data.post_number).then(() => refresh(data.id));
return;
}
case "created": {
postStream.triggerNewPostInStream(data.id);
if (self.get('currentUser.id') !== data.user_id) {
postStream.triggerNewPostInStream(data.id).then(() => refresh());
if (this.get('currentUser.id') !== data.user_id) {
Discourse.notifyBackgroundCountIncrement();
}
return;
@ -673,23 +718,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
},
// If our current post is changed, notify the router
_currentPostChanged: function() {
const currentPost = this.get('model.currentPost');
if (currentPost) {
this.send('postChangedRoute', currentPost);
}
}.observes('model.currentPost'),
readPosts(topicId, postNumbers) {
const topic = this.get("model"),
postStream = topic.get("postStream");
const topic = this.get("model");
const postStream = topic.get("postStream");
if (topic.get('id') === topicId) {
if (topic.get("id") === topicId) {
// TODO identity map for postNumber
_.each(postStream.get('posts'), post => {
if (_.include(postNumbers, post.post_number) && !post.read) {
post.set("read", true);
postStream.get('posts').forEach(post => {
if (!post.read && postNumbers.indexOf(post.post_number) !== -1) {
post.set('read', true);
this.appEvents.trigger('post-stream:refresh', { id: post.id });
}
});
@ -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() {
const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts");

View File

@ -45,7 +45,7 @@ export default Ember.Controller.extend({
}
// Don't show on mobile
if (Discourse.Mobile.mobileView) {
if (this.site.mobileView) {
const url = "/users/" + username;
DiscourseURL.routeTo(url);
return;

View File

@ -2,7 +2,6 @@ import computed from 'ember-addons/ember-computed-decorators';
import Topic from 'discourse/models/topic';
export default Ember.Controller.extend({
needs: ["application", "user-topics-list", "user"],
pmView: false,
viewingSelf: Em.computed.alias('controllers.user.viewingSelf'),
@ -11,10 +10,6 @@ export default Ember.Controller.extend({
selected: Em.computed.alias('controllers.user-topics-list.selected'),
bulkSelectEnabled: Em.computed.alias('controllers.user-topics-list.bulkSelectEnabled'),
mobileView: function() {
return Discourse.Mobile.mobileView;
}.property(),
showNewPM: function(){
return this.get('controllers.user.viewingSelf') &&
Discourse.User.currentProp('can_send_private_messages');

View File

@ -22,13 +22,11 @@ function loadingResolver(cb) {
}
function parseName(fullName) {
/*jshint validthis:true */
const nameParts = fullName.split(":"),
type = nameParts[0], fullNameWithoutType = nameParts[1],
name = fullNameWithoutType,
namespace = get(this, 'namespace'),
root = namespace;
type = nameParts[0], fullNameWithoutType = nameParts[1],
name = fullNameWithoutType,
namespace = get(this, 'namespace'),
root = namespace;
return {
fullName: fullName,
@ -85,6 +83,10 @@ export default Ember.DefaultResolver.extend({
return module;
},
resolveWidget(parsedName) {
return this.customResolve(parsedName) || this._super(parsedName);
},
resolveAdapter(parsedName) {
return this.customResolve(parsedName) || this._super(parsedName);
},
@ -139,7 +141,7 @@ export default Ember.DefaultResolver.extend({
},
findMobileTemplate(parsedName) {
if (Discourse.Mobile.mobileView) {
if (this.mobileView) {
var mobileParsedName = this.parseName(parsedName.fullName.replace("template:", "template:mobile/"));
return this.findTemplate(mobileParsedName);
}

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

View File

@ -1,3 +1,4 @@
import { h } from 'virtual-dom';
import registerUnbound from 'discourse/helpers/register-unbound';
function iconClasses(icon, params) {
@ -7,7 +8,7 @@ function iconClasses(icon, params) {
return classes;
}
function iconHTML(icon, params) {
export function iconHTML(icon, params) {
params = params || {};
var html = "<i class='" + iconClasses(icon, params) + "'";
@ -19,6 +20,24 @@ function iconHTML(icon, params) {
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) {
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) {
return new Handlebars.SafeString(iconHTML(icon, params));
});
export { iconHTML };

View File

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

View File

@ -0,0 +1,31 @@
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) {
const attributes = {
title: longDate(dt),
'data-time': dt.getTime(),
'data-format': 'tiny'
};
return h('span.relative-date', { attributes }, 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);
}

View File

@ -1,4 +1,4 @@
import { onToolbarCreate } from 'discourse/components/d-editor';
import { withPluginApi } from 'discourse/lib/plugin-api';
export default {
name: 'enable-emoji',
@ -7,13 +7,15 @@ export default {
const siteSettings = container.lookup('site-settings:main');
if (siteSettings.enable_emoji) {
onToolbarCreate(toolbar => {
toolbar.addButton({
id: 'emoji',
group: 'extras',
icon: 'smile-o',
action: 'emoji',
title: 'composer.emoji'
withPluginApi('0.1', api => {
api.onToolbarCreate(toolbar => {
toolbar.addButton({
id: 'emoji',
group: 'extras',
icon: 'smile-o',
action: 'emoji',
title: 'composer.emoji'
});
});
});

View File

@ -1,7 +1,7 @@
export default {
name: 'ensure-image-dimensions',
after: 'mobile',
initialize: function() {
initialize(container) {
if (!window) { return; }
// This enforces maximum dimensions of images based on site settings
@ -11,7 +11,8 @@ export default {
var width = Discourse.SiteSettings.max_image_width;
var height = Discourse.SiteSettings.max_image_height;
if (Discourse.Mobile.mobileView) {
const site = container.lookup('site:main');
if (site.mobileView) {
width = $(window).width() - 20;
}

View File

@ -1,14 +1,19 @@
/**
Initializes the `Discourse.Mobile` helper object.
**/
import Mobile from 'discourse/lib/mobile';
// Initializes the `Mobile` helper object.
export default {
name: 'mobile',
after: 'inject-objects',
initialize: function(container) {
Discourse.Mobile.init();
var site = container.lookup('site:main');
site.set('mobileView', Discourse.Mobile.mobileView);
initialize(container, app) {
Mobile.init();
const site = container.lookup('site:main');
site.set('mobileView', Mobile.mobileView);
site.set('isMobileDevice', Mobile.isMobileDevice);
// This is a bit weird but you can't seem to inject into the resolver?
app.registry.resolver.__resolver__.mobileView = Mobile.mobileView;
}
};

View File

@ -1,5 +1,5 @@
import { cleanDOM } from 'discourse/routes/discourse';
import PageTracker from 'discourse/lib/page-tracker';
import { startPageTracking, onPageChange } from 'discourse/lib/page-tracker';
export default {
name: "page-tracking",
@ -34,13 +34,12 @@ export default {
}
};
const pageTracker = PageTracker.current();
pageTracker.start();
startPageTracking(router);
// Out of the box, Discourse tries to track google analytics
// if it is present
if (typeof window._gaq !== 'undefined') {
pageTracker.on('change', function(url, title) {
onPageChange((url, title) => {
window._gaq.push(["_set", "title", title]);
window._gaq.push(['_trackPageview', url]);
});
@ -49,7 +48,7 @@ export default {
// Also use Universal Analytics if it is present
if (typeof window.ga !== 'undefined') {
pageTracker.on('change', function(url, title) {
onPageChange.on('change', (url, title) => {
window.ga('send', 'pageview', {page: url, title: title});
});
}

View File

@ -1,11 +1,13 @@
import { decorateCooked } from 'discourse/lib/plugin-api';
import HighlightSyntax from 'discourse/lib/highlight-syntax';
import Lightbox from 'discourse/lib/lightbox';
import highlightSyntax from 'discourse/lib/highlight-syntax';
import lightbox from 'discourse/lib/lightbox';
import { withPluginApi } from 'discourse/lib/plugin-api';
export default {
name: "post-decorations",
initialize: function(container) {
decorateCooked(container, HighlightSyntax);
decorateCooked(container, Lightbox);
initialize() {
withPluginApi('0.1', api => {
api.decorateCooked(highlightSyntax);
api.decorateCooked(lightbox);
});
}
};

View File

@ -1,25 +1,23 @@
import ScreenTrack from 'discourse/lib/screen-track';
import Session from 'discourse/models/session';
const ANON_TOPIC_IDS = 2,
ANON_PROMPT_READ_TIME = 2 * 60 * 1000,
ONE_DAY = 24 * 60 * 60 * 1000,
PROMPT_HIDE_DURATION = ONE_DAY;
const ANON_TOPIC_IDS = 2;
const ANON_PROMPT_READ_TIME = 2 * 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
const PROMPT_HIDE_DURATION = ONE_DAY;
export default {
name: "signup-cta",
initialize(container) {
const screenTrack = ScreenTrack.current(),
session = Session.current(),
siteSettings = container.lookup('site-settings:main'),
keyValueStore = container.lookup('key-value-store:main'),
user = container.lookup('current-user:main');
const screenTrack = container.lookup('screen-track:main');
const session = Session.current();
const siteSettings = container.lookup('site-settings:main');
const keyValueStore = container.lookup('key-value-store:main');
const user = container.lookup('current-user:main');
screenTrack.set('keyValueStore', keyValueStore);
screenTrack.keyValueStore = keyValueStore;
// Preconditions
if (user) return; // must not be logged in
if (keyValueStore.get('anon-cta-never')) return; // "never show again"
if (!siteSettings.allow_new_registrations) return;
@ -63,7 +61,7 @@ export default {
session.set('showSignupCta', true);
}
screenTrack.set('anonFlushCallback', checkSignupCtaRequirements);
screenTrack.registerAnonCallback(checkSignupCtaRequirements);
checkSignupCtaRequirements();
}

View File

@ -96,7 +96,7 @@ export default {
});
if (!Ember.testing) {
if (!Discourse.Mobile.mobileView) {
if (!site.mobileView) {
bus.subscribe("/notification-alert/" + user.get('id'), function(data){
onNotification(data, user);
});

View File

@ -3,7 +3,6 @@
@module $.fn.autocomplete
**/
export var CANCELLED_STATUS = "__CANCELLED";
const allowedLettersRegex = /[\s\t\[\{\(\/]/;
@ -45,7 +44,7 @@ export default function(options) {
if (options === 'destroy') {
Ember.run.cancel(inputTimeout);
$(this).off('keypress.autocomplete')
$(this).off('keyup.autocomplete')
.off('keydown.autocomplete')
.off('paste.autocomplete')
.off('click.autocomplete');
@ -59,7 +58,13 @@ export default function(options) {
}
if (this.length !== 1) {
alert("only supporting one matcher at the moment");
if (window.console) {
window.console.log("WARNING: passed multiple elements to $.autocomplete, skipping.");
if (window.Error) {
window.console.log((new window.Error()).stack);
}
}
return this;
}
var disabled = options && options.disabled;
@ -226,7 +231,7 @@ export default function(options) {
vOffset = div.height();
}
if (Discourse.Mobile.mobileView && !isInput) {
if (Discourse.Site.currentProp('mobileView') && !isInput) {
div.css('width', 'auto');
if ((me.height() / 2) >= pos.top) { vOffset = -23; }
@ -242,7 +247,15 @@ export default function(options) {
});
};
const SKIP = "skip";
var prevTerm = null;
const dataSource = (term, opts) => {
if (prevTerm === term) {
return SKIP;
}
prevTerm = term;
if (term.length !== 0 && term.trim().length === 0) {
closeAutocomplete();
return null;
@ -251,9 +264,9 @@ export default function(options) {
}
};
var updateAutoComplete = function(r) {
const updateAutoComplete = function(r) {
if (completeStart === null) return;
if (completeStart === null || r === SKIP) return;
if (r && r.then && typeof(r.then) === "function") {
if (div) {
@ -304,21 +317,21 @@ export default function(options) {
}
};
$(this).on('keypress.autocomplete', function(e) {
var caretPosition, term;
$(this).on('keyup.autocomplete', function() {
// keep hunting backwards till you hit a the @ key
if (options.key && e.which === options.key.charCodeAt(0)) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
var prevChar = me.val().charAt(caretPosition - 1);
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = caretPosition;
updateAutoComplete(dataSource("", options));
var caretPosition = Discourse.Utilities.caretPosition(me[0]);
if (options.key && completeStart === null && caretPosition > 0) {
var key = me[0].value[caretPosition-1];
if (key === options.key) {
var prevChar = me.val().charAt(caretPosition-2);
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = caretPosition-1;
updateAutoComplete(dataSource("", options));
}
}
} else if ((completeStart !== null) && (e.charCode !== 0)) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition);
term += String.fromCharCode(e.charCode);
} else if (completeStart !== null) {
var term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition);
updateAutoComplete(dataSource(term, options));
}
});
@ -331,7 +344,7 @@ export default function(options) {
}
if(options.allowAny){
// saves us wiring up a change event as well, keypress is while its pressed
// saves us wiring up a change event as well
Ember.run.cancel(inputTimeout);
inputTimeout = Ember.run.later(function(){

View File

@ -1,6 +1,6 @@
import DiscourseURL from 'discourse/lib/url';
import PageTracker from 'discourse/lib/page-tracker';
import KeyValueStore from 'discourse/lib/key-value-store';
import { onPageChange } from 'discourse/lib/page-tracker';
let primaryTab = false;
let liveEnabled = false;
@ -84,7 +84,8 @@ function setupNotifications() {
if (document) {
document.addEventListener("scroll", resetIdle);
}
PageTracker.on("change", resetIdle);
onPageChange(resetIdle);
}
function resetIdle() {

View File

@ -1,5 +1,3 @@
import CloakedCollectionView from 'discourse/views/cloaked-collection';
/**
@module Discourse
*/
@ -33,7 +31,12 @@ const DiscourseLocation = Ember.Object.extend({
@method initState
*/
initState() {
set(this, 'history', get(this, 'history') || window.history);
const history = get(this, 'history') || window.history;
if (history && history.scrollRestoration) {
history.scrollRestoration = "manual";
}
set(this, 'history', history);
let url = this.formatURL(this.getURL());
const loc = get(this, 'location');
@ -221,36 +224,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;

View File

@ -161,7 +161,7 @@ function showSelector(options) {
options.appendTo.append('<div class="emoji-modal-wrapper"></div>');
$('.emoji-modal-wrapper').click(() => closeSelector());
if (Discourse.Mobile.mobileView) PER_ROW = 9;
if (Discourse.Site.currentProp('mobileView')) { PER_ROW = 9; }
const page = keyValueStore.getInt("emojiPage", 0);
const offset = keyValueStore.getInt("emojiOffset", 0);

View File

@ -28,7 +28,7 @@ const bindings = {
'home': {handler: 'goToFirstPost', anonymous: true},
'j': {handler: 'selectDown', anonymous: true},
'k': {handler: 'selectUp', anonymous: true},
'l': {click: '.topic-post.selected button[data-action="like"]'},
'l': {click: '.topic-post.selected button.toggle-like'},
'm m': {click: 'div.notification-options li[data-id="0"] a'}, // mark topic as muted
'm r': {click: 'div.notification-options li[data-id="1"] a'}, // mark topic as regular
'm t': {click: 'div.notification-options li[data-id="2"] a'}, // mark topic as tracking
@ -222,10 +222,14 @@ export default {
// TODO: We should keep track of the post without a CSS class
const selectedPostId = parseInt($('.topic-post.selected article.boxed').data('post-id'), 10);
if (selectedPostId) {
const topicController = container.lookup('controller:topic'),
post = topicController.get('model.postStream.posts').findBy('id', selectedPostId);
const topicController = container.lookup('controller:topic');
const post = topicController.get('model.postStream.posts').findBy('id', selectedPostId);
if (post) {
topicController.send(action, post);
// TODO: Use ember closure actions
const result = topicController._actions[action].call(topicController, post);
if (result && result.then) {
this.appEvents.trigger('post-stream:refresh', { id: selectedPostId });
}
}
}
},
@ -312,12 +316,7 @@ export default {
}
if ($article.is('.topic-post')) {
let tabLoc = $article.find('a.tabLoc');
if (tabLoc.length === 0) {
tabLoc = $('<a href class="tabLoc"></a>');
$article.prepend(tabLoc);
}
tabLoc.focus();
$('a.tabLoc', $article).focus();
}
this._scrollList($article, direction);

View File

@ -1,10 +1,10 @@
// An object that is responsible for logic related to mobile devices.
Discourse.Mobile = {
const Mobile = {
isMobileDevice: false,
mobileView: false,
init: function() {
var $html = $('html');
init() {
const $html = $('html');
this.isMobileDevice = $html.hasClass('mobile-device');
this.mobileView = $html.hasClass('mobile-view');
@ -42,3 +42,13 @@ Discourse.Mobile = {
window.location.assign(window.location.pathname + '?mobile_view=' + (mobile ? '1' : '0'));
}
};
// Backwards compatibiltity, deprecated
Object.defineProperty(Discourse, 'Mobile', {
get: function() {
Ember.warn("DEPRECATION: `Discourse.Mobile` is deprecated, use `this.site.mobileView` instead");
return Mobile;
}
});
export default Mobile;

View File

@ -1,37 +1,31 @@
import Singleton from 'discourse/mixins/singleton';
const PageTracker = Ember.Object.extend(Ember.Evented);
let _pageTracker = PageTracker.create();
/**
Called whenever the "page" changes. This allows us to set up analytics
and other tracking.
let _started = false;
export function startPageTracking(router) {
if (_started) { return; }
To get notified when the page changes, you can install a hook like so:
router.on('didTransition', function() {
this.send('refreshTitle');
const url = Discourse.getURL(this.get('url'));
```javascript
PageTracker.current().on('change', function(url, title) {
console.log('the page changed to: ' + url + ' and title ' + title);
// Refreshing the title is debounced, so we need to trigger this in the
// next runloop to have the correct title.
Em.run.next(() => {
_pageTracker.trigger('change', url, Discourse.get('_docTitle'));
});
```
**/
const PageTracker = Ember.Object.extend(Ember.Evented, {
start: function() {
if (this.get('started')) { return; }
});
_started = true;
}
var router = Discourse.__container__.lookup('router:main'),
self = this;
export function onPageChange(fn) {
_pageTracker.on('change', fn);
}
router.on('didTransition', function() {
this.send('refreshTitle');
var url = Discourse.getURL(this.get('url'));
// Refreshing the title is debounced, so we need to trigger this in the
// next runloop to have the correct title.
Em.run.next(function() {
self.trigger('change', url, Discourse.get('_docTitle'));
});
});
this.set('started', true);
// backwards compatibility
export default {
current() {
console.warn(`Using PageTracker.current() is deprecated. Your plugin should use the PluginAPI`);
return _pageTracker;
}
});
PageTracker.reopenClass(Singleton);
export default PageTracker;
};

View File

@ -1,4 +1,275 @@
import { iconNode } from 'discourse/helpers/fa-icon';
import { addDecorator } from 'discourse/widgets/post-cooked';
import ComposerEditor from 'discourse/components/composer-editor';
import { addButton } from 'discourse/widgets/post-menu';
import { includeAttributes } from 'discourse/lib/transform-post';
import { addToolbarCallback } from 'discourse/components/d-editor';
import { addWidgetCleanCallback } from 'discourse/components/mount-widget';
import { decorateWidget } from 'discourse/widgets/widget';
import { onPageChange } from 'discourse/lib/page-tracker';
class PluginApi {
constructor(version, container) {
this.version = version;
this.container = container;
this._currentUser = container.lookup('current-user:main');
}
/**
* Use this function to retrieve the currently logged in user within your plugin.
* If the user is not logged in, it will be `null`.
**/
getCurrentUser() {
return this._currentUser;
}
/**
* Used for decorating the `cooked` content of a post after it is rendered using
* jQuery.
*
* `callback` will be called when it is time to decorate with a jQuery selector.
*
* Use `options.onlyStream` if you only want to decorate posts within a topic,
* and not in other places like the user stream.
*
* For example, to add a yellow background to all posts you could do this:
*
* ```
* api.decorateCooked($elem => $elem.css({ backgroundColor: 'yellow' }));
* ```
**/
decorateCooked(callback, opts) {
opts = opts || {};
addDecorator(callback);
if (!opts.onlyStream) {
decorate(ComposerEditor, 'previewRefreshed', callback);
decorate(this.container.lookupFactory('view:user-stream'), 'didInsertElement', callback);
}
}
/**
* addPosterIcon(callback)
*
* This function can be used to add an icon with a link that will be displayed
* beside a poster's name. The `callback` is called with the post's user custom
* fields and post attrions. An icon will be rendered if the callback returns
* an object with the appropriate attributes.
*
* The returned object can have the following attributes:
*
* icon the font awesome icon to render
* emoji an emoji icon to render
* className (optional) a css class to apply to the icon
* url (optional) where to link the icon
* title (optional) the tooltip title for the icon on hover
*
* ```
* api.addPosterIcon((cfs, attrs) => {
* if (cfs.customer) {
* return { icon: 'user', className: 'customer', title: 'customer' };
* }
* });
* ```
**/
addPosterIcon(cb) {
decorateWidget('poster-name:after', dec => {
const attrs = dec.attrs;
const result = cb(attrs.userCustomFields || {}, attrs);
if (result) {
let iconBody;
if (result.icon) {
iconBody = iconNode(result.icon);
} else if (result.emoji) {
iconBody = result.emoji.split('|').map(emoji => {
const src = Discourse.Emoji.urlFor(emoji);
return dec.h('img', { className: 'emoji', attributes: { src } });
});
}
if (result.text) {
iconBody = [iconBody, result.text];
}
if (result.url) {
iconBody = dec.h('a', { attributes: { href: result.url } }, iconBody);
}
return dec.h('span',
{ className: result.className, attributes: { title: result.title } },
iconBody);
}
});
}
/**
* The main interface for extending widgets with additional HTML.
*
* The `name` you pass it should be the name of the widget and a type
* for the decorator. All widgets support `before` and `after` types.
*
* Example:
*
* ```
* api.decorateWidget('post:after', () => {
* return "I am displayed after every post!";
* });
* ```
*
* Your decorator will be called with an instance of a `DecoratorHelper`
* object, which provides methods you can use to build more interesting
* formatting.
*
* ```
* api.decorateWidget('post:after', helper => {
* return helper.h('p.fancy', `I'm an HTML paragraph on post with id ${helper.attrs.id}`);
* });
*
* (View the source for `DecoratorHelper` for more helper methods you
* can use in your plugin decorators.)
*
**/
decorateWidget(name, fn) {
decorateWidget(name, fn);
}
/**
* Adds a new action to a widget that already exists. You can use this to
* add additional functionality from your plugin.
*
* Example:
*
* ```
* api.attachWidgetAction('post', 'annoyMe', () => {
* alert('ANNOYED!');
* });
* ```
**/
attachWidgetAction(widget, actionName, fn) {
const widgetClass = this.container.lookupFactory(`widget:${widget}`);
widgetClass.prototype[actionName] = fn;
}
/**
* Add more attributes to the Post's `attrs` object passed through to widgets.
* You'll need to do this if you've added attributes to the serializer for a
* Post and want to use them when you're rendering.
*
* Example:
*
* ```
* // attrs.poster_age and attrs.poster_height will be present
* api.includePostAttributes('poster_age', 'poster_height');
* ```
*
**/
includePostAttributes(...attributes) {
includeAttributes(...attributes);
}
/**
* Add a new button below a post with your plugin.
*
* The `callback` function will be called whenever the post menu is rendered,
* and if you return an object with the button details it will be rendered.
*
* Example:
*
* ```
* api.addPostMenuButton('coffee', () => {
* return {
* action: 'drinkCoffee',
* icon: 'coffee',
* className: 'hot-coffee',
* title: 'coffee.title',
* position: 'first' // can be `first`, `last` or `second-last-hidden`
* };
* });
**/
addPostMenuButton(name, callback) {
addButton(name, callback);
}
/**
* A hook that is called when the editor toolbar is created. You can
* use this to add custom editor buttons.
*
* Example:
*
* ```
* api.onToolbarCreate(toolbar => {
* toolbar.addButton({
* id: 'pop-text',
* group: 'extras',
* icon: 'bolt',
* action: 'makeItPop',
* title: 'pop_format.title'
* });
* });
**/
onToolbarCreate(callback) {
addToolbarCallback(callback);
}
/**
* A hook that is called when the post stream is removed from the DOM.
* This advanced hook should be used if you end up wiring up any
* events that need to be torn down when the user leaves the topic
* page.
**/
cleanupStream(fn) {
addWidgetCleanCallback('post-stream', fn);
}
/**
Called whenever the "page" changes. This allows us to set up analytics
and other tracking.
To get notified when the page changes, you can install a hook like so:
```javascript
api.onPageChange((url, title) => {
console.log('the page changed to: ' + url + ' and title ' + title);
});
```
**/
onPageChange(fn) {
onPageChange(fn);
}
}
let _pluginv01;
function getPluginApi(version) {
if (version === "0.1") {
if (!_pluginv01) {
_pluginv01 = new PluginApi(version, Discourse.__container__);
}
return _pluginv01;
} else {
console.warn(`Plugin API v${version} is not supported`);
}
}
/**
* withPluginApi(version, apiCode, noApi)
*
* Helper to version our client side plugin API. Pass the version of the API that your
* plugin is coded against. If that API is available, the `apiCodeCallback` function will
* be called with the `PluginApi` object.
*/
export function withPluginApi(version, apiCodeCallback, opts) {
opts = opts || {};
const api = getPluginApi(version);
if (api) {
return apiCodeCallback(api);
}
}
let _decorateId = 0;
function decorate(klass, evt, cb) {
@ -7,38 +278,6 @@ function decorate(klass, evt, cb) {
klass.reopen(mixin);
}
export function decorateCooked(container, cb) {
const postView = container.lookupFactory('view:post');
decorate(postView, 'postViewInserted', cb);
decorate(postView, 'postViewUpdated', cb);
decorate(ComposerEditor, 'previewRefreshed', cb);
decorate(container.lookupFactory('view:embedded-post'), 'didInsertElement', cb);
decorate(container.lookupFactory('view:user-stream'), 'didInsertElement', cb);
}
// This is backported so plugins in the new format will not raise errors
//
// To upgrade your plugin for backwards compatibility, you can add code in this
// form:
//
// function newApiCode(api) {
// // api.xyz();
// }
//
// function oldCode() {
// // your pre-PluginAPI code goes here. You will be able to delete this
// // code once the `PluginAPI` has been rolled out to all versions of
// // Discourse you want to support.
// }
//
// // `newApiCode` will use API version 0.1, if no API support then
// // `oldCode` will be called
// withPluginApi('0.1', newApiCode, { noApi: oldCode });
//
export function withPluginApi(version, apiCodeCallback, opts) {
console.warn(`Plugin API v${version} is not supported`);
if (opts && opts.noApi) {
return opts.noApi();
}
export function decorateCooked() {
console.warn('`decorateCooked` has been removed. Use `getPluginApi(version).decorateCooked` instead');
}

View File

@ -1,6 +1,8 @@
import { Placeholder } from 'discourse/views/cloaked';
import { default as computed } from 'ember-addons/ember-computed-decorators';
export function Placeholder(viewName) {
this.viewName = viewName;
}
export default Ember.Object.extend(Ember.Array, {
posts: null,
@ -34,6 +36,11 @@ export default Ember.Object.extend(Ember.Array, {
this._changeArray(cb, this.get('posts.length') - 1, 1, 0);
},
refreshAll(cb) {
const length = this.get('posts.length');
this._changeArray(cb, 0, length, length);
},
appending(postIds) {
this._changeArray(() => {
const appendingIds = this._appendingIds;

View File

@ -37,8 +37,6 @@ function positioningWorkaround($fixedElement) {
if (evt) {
evt.target.removeEventListener('blur', blurred);
}
$('body').removeData('disable-cloaked-view');
};
var blurred = _.debounce(blurredNow, 250);
@ -63,7 +61,6 @@ function positioningWorkaround($fixedElement) {
// take care of body
$('body').data('disable-cloaked-view',true);
$('#main-outlet').hide();
$('header').hide();

View File

@ -1,22 +1,21 @@
// 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,
MAX_TRACKING_TIME = 1000 * 60 * 6,
ANON_MAX_TOPIC_IDS = 5;
const ScreenTrack = Ember.Object.extend({
init() {
export default class {
constructor(topicTrackingState, siteSettings, session, currentUser) {
this.topicTrackingState = topicTrackingState;
this.siteSettings = siteSettings;
this.session = session;
this.currentUser = currentUser;
this.reset();
// TODO: Move `ScreenTrack` to injection and remove this
this.set('topicTrackingState', Discourse.__container__.lookup('topic-tracking-state:main'));
},
}
start(topicId, topicController) {
const currentTopicId = this.get('topicId');
const currentTopicId = this._topicId;
if (currentTopicId && (currentTopicId !== topicId)) {
this.tick();
this.flush();
@ -25,90 +24,81 @@ const ScreenTrack = Ember.Object.extend({
this.reset();
// Create an interval timer if we don't have one.
if (!this.get('interval')) {
const self = this;
this.set('interval', setInterval(function () {
self.tick();
}, 1000));
$(window).on('scroll.screentrack', function(){self.scrolled();});
if (!this._interval) {
this._interval = setInterval(() => this.tick(), 1000);
$(window).on('scroll.screentrack', () => this.scrolled());
}
this.set('topicId', topicId);
this.set('topicController', topicController);
},
this._topicId = topicId;
this._topicController = topicController;
}
stop() {
if(!this.get('topicId')) {
// already stopped no need to "extra stop"
return;
}
// already stopped no need to "extra stop"
if(!this._topicId) { return; }
$(window).off('scroll.screentrack');
this.tick();
this.flush();
this.reset();
this.set('topicId', null);
this.set('topicController', null);
if (this.get('interval')) {
clearInterval(this.get('interval'));
this.set('interval', null);
this._topicId = null;
this._topicController = null;
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
},
}
track(elementId, postNumber) {
this.get('timings')["#" + elementId] = {
time: 0,
postNumber: postNumber
};
},
stopTracking(elementId) {
delete this.get('timings')['#' + elementId];
},
setOnscreen(onscreen) {
this._onscreen = onscreen;
}
// Reset our timers
reset() {
this.setProperties({
lastTick: new Date().getTime(),
lastScrolled: new Date().getTime(),
lastFlush: 0,
cancelled: false,
timings: {},
totalTimings: {},
topicTime: 0
});
},
const now = getTime();
this._lastTick = now;
this._lastScrolled = now;
this._lastFlush = 0;
this._timings = {};
this._totalTimings = {};
this._topicTime = 0;
this._onscreen = [];
}
scrolled() {
this.set('lastScrolled', new Date().getTime());
},
this._lastScrolled = getTime();
}
registerAnonCallback(cb) {
this._anonCallback = cb;
}
flush() {
if (this.get('cancelled')) { return; }
const newTimings = {};
const totalTimings = this._totalTimings;
const newTimings = {},
totalTimings = this.get('totalTimings'),
self = this;
const timings = this._timings;
Object.keys(this._timings).forEach(postNumber => {
const time = timings[postNumber];
totalTimings[postNumber] = totalTimings[postNumber] || 0;
_.each(this.get('timings'), function(timing) {
if (!totalTimings[timing.postNumber])
totalTimings[timing.postNumber] = 0;
if (timing.time > 0 && totalTimings[timing.postNumber] < MAX_TRACKING_TIME) {
totalTimings[timing.postNumber] += timing.time;
newTimings[timing.postNumber] = timing.time;
if (time > 0 && totalTimings[postNumber] < MAX_TRACKING_TIME) {
totalTimings[postNumber] += time;
newTimings[postNumber] = time;
}
timing.time = 0;
timings[postNumber] = 0;
});
const topicId = parseInt(this.get('topicId'), 10);
const topicId = parseInt(this._topicId, 10);
let highestSeen = 0;
_.each(newTimings, function(time,postNumber) {
Object.keys(newTimings).forEach(postNumber => {
highestSeen = Math.max(highestSeen, parseInt(postNumber, 10));
});
const highestSeenByTopic = Discourse.Session.currentProp('highestSeenByTopic');
const highestSeenByTopic = this.session.get('highestSeenByTopic');
if ((highestSeenByTopic[topicId] || 0) < highestSeen) {
highestSeenByTopic[topicId] = highestSeen;
}
@ -116,11 +106,11 @@ const ScreenTrack = Ember.Object.extend({
this.topicTrackingState.updateSeen(topicId, highestSeen);
if (!$.isEmptyObject(newTimings)) {
if (Discourse.User.current()) {
if (this.currentUser) {
Discourse.ajax('/topics/timings', {
data: {
timings: newTimings,
topic_time: this.get('topicTime'),
topic_time: this._topicTime,
topic_id: topicId
},
cache: false,
@ -128,22 +118,20 @@ const ScreenTrack = Ember.Object.extend({
headers: {
'X-SILENCE-LOGGER': 'true'
}
}).then(function() {
const controller = self.get('topicController');
}).then(() => {
const controller = this._topicController;
if (controller) {
const postNumbers = Object.keys(newTimings).map(function(v) {
return parseInt(v, 10);
});
const postNumbers = Object.keys(newTimings).map(v => parseInt(v, 10));
controller.readPosts(topicId, postNumbers);
}
});
} else if (this.get('anonFlushCallback')) {
} else if (this._anonCallback) {
// Anonymous viewer - save to localStorage
const storage = this.get('keyValueStore');
const storage = this.keyValueStore;
// Save total 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
let topicIds = storage.get('anon-topic-ids');
@ -158,64 +146,47 @@ const ScreenTrack = Ember.Object.extend({
}
// Inform the observer
this.get('anonFlushCallback')();
this._anonCallback();
// No need to call controller.readPosts()
}
this.set('topicTime', 0);
this._topicTime = 0;
}
this.set('lastFlush', 0);
},
this._lastFlush = 0;
}
tick() {
const now = new Date().getTime();
// 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) {
return;
}
const diff = new Date().getTime() - this.get('lastTick');
this.set('lastFlush', this.get('lastFlush') + diff);
this.set('lastTick', new Date().getTime());
const diff = now - this._lastTick;
this._lastFlush += diff;
this._lastTick = now;
const totalTimings = this.get('totalTimings'), timings = this.get('timings');
const nextFlush = Discourse.SiteSettings.flush_timings_secs * 1000;
const totalTimings = this._totalTimings;
const timings = this._timings;
const nextFlush = this.siteSettings.flush_timings_secs * 1000;
// rush new post numbers
const rush = _.any(_.filter(timings, function(t){return t.time>0;}), function(t){
return !totalTimings[t.postNumber];
const rush = Object.keys(timings).some(postNumber => {
return timings[postNumber] > 0 && !totalTimings[postNumber];
});
if (this.get('lastFlush') > nextFlush || rush) {
if (this._lastFlush > nextFlush || rush) {
this.flush();
}
// Don't track timings if we're not in focus
if (!Discourse.get("hasFocus")) return;
this.set('topicTime', this.get('topicTime') + diff);
const docViewTop = $(window).scrollTop() + $('header').height(),
docViewBottom = docViewTop + $(window).height();
this._topicTime += diff;
// TODO: Eyeline has a smarter more accurate function here. It's bad to do jQuery
// 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;
}
}
});
this._onscreen.forEach(postNumber => timings[postNumber] = (timings[postNumber] || 0) + diff);
}
});
ScreenTrack.reopenClass(Singleton);
export default ScreenTrack;
}

View File

@ -0,0 +1,201 @@
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 });
}
}
const _additionalAttributes = [];
export function includeAttributes(...attributes) {
attributes.forEach(a => _additionalAttributes.push(a));
}
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.get('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;
postAtts.userCustomFields = post.user_custom_fields;
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.liked = likeAction.acted;
postAtts.canToggleLike = likeAction.get('canToggle');
postAtts.showLike = postAtts.liked || postAtts.canToggleLike;
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;
}
_additionalAttributes.forEach(a => postAtts[a] = post[a]);
return postAtts;
}

View File

@ -14,10 +14,9 @@ const DiscourseURL = Ember.Object.createWithMixins({
/**
Jumps to a particular post in the stream
**/
jumpToPost: function(postNumber, opts) {
const holderId = `.post-cloak[data-post-number=${postNumber}]`;
const offset = function() {
jumpToPost(postNumber, opts) {
const holderId = `#post_${postNumber}`;
const offset = () => {
const $header = $('header');
const $title = $('#topic-title');
const windowHeight = $(window).height() - $title.height();
@ -26,8 +25,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset);
};
Em.run.schedule('afterRender', function() {
Em.run.schedule('afterRender', () => {
if (postNumber === 1) {
$(window).scrollTop(0);
return;
@ -37,21 +35,18 @@ const DiscourseURL = Ember.Object.createWithMixins({
const holder = $(holderId);
if (holder.length > 0 && opts && opts.skipIfOnScreen){
// if we are on screen skip
const elementTop = lockon.elementTop(),
scrollTop = $(window).scrollTop(),
windowHeight = $(window).height()-offset(),
height = holder.height();
scrollTop = $(window).scrollTop(),
windowHeight = $(window).height()-offset(),
height = holder.height();
if (elementTop > scrollTop &&
(elementTop + height) < (scrollTop + windowHeight)) {
if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) {
return;
}
}
lockon.lock();
});
},

View File

@ -3,20 +3,6 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
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() {
return this.get('can_undo') || this.get('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')) {
this.act(post);
return true;
@ -42,7 +35,7 @@ export default RestModel.extend({
},
// Perform this action
act: function(post, opts) {
act(post, opts) {
if (!opts) opts = {};
@ -83,37 +76,20 @@ export default RestModel.extend({
},
// Undo this action
undo: function(post) {
undo(post) {
this.removeAction(post);
// Remove our post action
return Discourse.ajax("/post_actions/" + post.get('id'), {
type: 'DELETE',
data: {
post_action_type_id: this.get('id')
}
}).then(function(result) {
return post.updateActionsSummary(result);
});
data: { post_action_type_id: this.get('id') }
}).then(result => post.updateActionsSummary(result));
},
deferFlags: function(post) {
const self = this;
deferFlags(post) {
return Discourse.ajax("/post_actions/defer_flags", {
type: "POST",
data: {
post_action_type_id: this.get("id"),
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')
});
data: { post_action_type_id: this.get("id"), id: post.get('id') }
}).then(() => this.set('count', 0));
}
});

View File

@ -154,7 +154,7 @@ const Composer = RestModel.extend({
usernameLink
});
if (!Discourse.Mobile.mobileView) {
if (!this.site.mobileView) {
const replyUsername = post.get('reply_to_user.username');
const replyAvatarTemplate = post.get('reply_to_user.avatar_template');
if (replyUsername && replyAvatarTemplate && this.get('action') === EDIT) {

View File

@ -7,7 +7,7 @@ const NavItem = Discourse.Model.extend({
name = this.get('name'),
count = this.get('count') || 0;
if (name === 'latest' && !Discourse.Mobile.mobileView) {
if (name === 'latest' && !Discourse.Site.currentProp('mobileView')) {
count = 0;
}

View File

@ -4,21 +4,6 @@ import PostsWithPlaceholders from 'discourse/lib/posts-with-placeholders';
import { default as computed } from 'ember-addons/ember-computed-decorators';
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({
_identityMap: null,
posts: null,
@ -295,6 +280,7 @@ export default RestModel.extend({
if (idx !== -1) {
stream.pushObjects(gap);
return this.appendMore().then(() => {
delete this.get('gaps.after')[postId];
this.get('stream').enumerableContentDidChange();
});
}
@ -377,7 +363,6 @@ export default RestModel.extend({
// Commit the post we staged. Call this after a save succeeds.
commitPost(post) {
if (this.get('topic.id') === post.get('topic_id')) {
if (this.get('loadedAllPosts')) {
this.appendPost(post);
@ -414,7 +399,6 @@ export default RestModel.extend({
const stored = this.storePost(post);
if (stored) {
const posts = this.get('posts');
calcDayDiff(posts.get('firstObject'), stored);
posts.unshiftObject(stored);
}
@ -426,7 +410,6 @@ export default RestModel.extend({
if (stored) {
const posts = this.get('posts');
calcDayDiff(stored, this.get('lastAppended'));
if (!posts.contains(stored)) {
if (!this.get('loadingBelow')) {
this.get('postsWithPlaceholders').appendPost(() => posts.pushObject(stored));
@ -445,12 +428,15 @@ export default RestModel.extend({
removePosts(posts) {
if (Ember.isEmpty(posts)) { return; }
const postIds = posts.map(p => p.get('id'));
const identityMap = this._identityMap;
this.get('postsWithPlaceholders').refreshAll(() => {
const allPosts = this.get('posts');
const postIds = posts.map(p => p.get('id'));
const identityMap = this._identityMap;
this.get('stream').removeObjects(postIds);
this.get('posts').removeObjects(posts);
postIds.forEach(id => delete identityMap[id]);
this.get('stream').removeObjects(postIds);
allPosts.removeObjects(posts);
postIds.forEach(id => delete identityMap[id]);
});
},
// Returns a post from the identity map if it's been inserted.
@ -471,10 +457,12 @@ export default RestModel.extend({
have no filters.
**/
triggerNewPostInStream(postId) {
if (!postId) { return; }
const resolved = Ember.RSVP.Promise.resolve();
if (!postId) { return resolved; }
// 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');
@ -482,25 +470,27 @@ export default RestModel.extend({
this.get('stream').addObject(postId);
if (loadedAllPosts) {
this.set('loadingLastPost', true);
this.findPostsByIds([postId]).then(posts => {
return this.findPostsByIds([postId]).then(posts => {
posts.forEach(p => this.appendPost(p));
}).finally(() => {
this.set('loadingLastPost', false);
});
}
}
return resolved;
},
triggerRecoveredPost(postId) {
const existing = this._identityMap[postId];
if (existing) {
this.triggerChangedPost(postId, new Date());
return this.triggerChangedPost(postId, new Date());
} else {
// need to insert into stream
const url = "/posts/" + postId;
const store = this.store;
Discourse.ajax(url).then(p => {
return Discourse.ajax(url).then(p => {
const post = store.createRecord('post', p);
const stream = this.get("stream");
const posts = this.get("posts");
@ -541,34 +531,26 @@ export default RestModel.extend({
const url = "/posts/" + postId;
const store = this.store;
Discourse.ajax(url).then(p => {
return Discourse.ajax(url).then(p => {
this.storePost(store.createRecord('post', p));
}).catch(() => {
this.removePosts([existing]);
});
}
return Ember.RSVP.Promise.resolve();
},
triggerChangedPost(postId, updatedAt) {
if (!postId) { return; }
const resolved = Ember.RSVP.Promise.resolve();
if (!postId) { return resolved; }
const existing = this._identityMap[postId];
if (existing && existing.updated_at !== updatedAt) {
const url = "/posts/" + postId;
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)));
}
},
// 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);
});
return resolved;
},
/**

View File

@ -7,10 +7,6 @@ import computed from 'ember-addons/ember-computed-decorators';
const Post = RestModel.extend({
init() {
this.set('replyHistory', []);
},
@computed()
siteSettings() {
// 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'),
deleted: Em.computed.or('deleted_at', 'deletedViaTopic'),
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() {
const name = this.get('name');
@ -68,25 +59,13 @@ const Post = RestModel.extend({
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'),
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) {
const data = {};
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.incrementProperty("version");
}).catch(popupAjaxError);
@ -97,9 +76,6 @@ const Post = RestModel.extend({
return this.get('link_counts').filterProperty('internal').filterProperty('title');
}.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() {
const post = this;
return Discourse.Site.currentProp('flagTypes').filter(function(item) {
@ -107,17 +83,6 @@ const Post = RestModel.extend({
});
}.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) {
if (res.category) {
Discourse.Site.current().updateCategory(res.category);
@ -246,10 +211,6 @@ const Post = RestModel.extend({
let value = otherPost[key],
oldValue = self[key];
if (key === "replyHistory") {
return;
}
if (!value) { value = 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() {
const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) {
self.setProperties({
cooked: result.cooked,
cooked_hidden: false
});
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(result => {
this.setProperties({ cooked: result.cooked, cooked_hidden: false });
});
},

View File

@ -37,7 +37,9 @@ function findAndRemoveMap(type, id) {
flushMap();
export default Ember.Object.extend({
_plurals: {},
_plurals: {'post-reply': 'post-replies',
'post-reply-history': 'post_reply_histories'},
pluralize(thing) {
return this._plurals[thing] || thing + "s";
},

View File

@ -34,12 +34,6 @@ const TopicDetails = RestModel.extend({
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() {
var level = this.get('notification_level');
if(typeof level !== 'number'){
@ -68,13 +62,13 @@ const TopicDetails = RestModel.extend({
},
removeAllowedUser(user) {
var users = this.get('allowed_users'),
username = user.get('username');
const users = this.get('allowed_users');
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',
data: { username: username }
}).then(function() {
}).then(() => {
users.removeObject(users.findProperty('username', username));
});
}

View File

@ -224,34 +224,37 @@ const Topic = RestModel.extend({
.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() {
if (this.get("bookmarking")) { return; }
if (this.get('bookmarking')) { return Ember.RSVP.Promise.resolve(); }
this.set("bookmarking", true);
const self = this,
stream = this.get('postStream'),
posts = Em.get(stream, 'posts'),
firstPost = posts && posts[0] && posts[0].get('post_number') === 1 && posts[0],
bookmark = !this.get('bookmarked'),
path = bookmark ? '/bookmark' : '/remove_bookmarks';
const stream = this.get('postStream');
const posts = Em.get(stream, 'posts');
const firstPost = posts && posts[0] && posts[0].get('post_number') === 1 && posts[0];
const bookmark = !this.get('bookmarked');
const path = bookmark ? '/bookmark' : '/remove_bookmarks';
const toggleBookmarkOnServer = function() {
return Discourse.ajax('/t/' + self.get('id') + path, {
type: 'PUT',
}).then(function() {
self.toggleProperty('bookmarked');
if (bookmark && firstPost) { firstPost.set('bookmarked', true); }
if (!bookmark && posts) {
posts.forEach((post) => post.get('bookmarked') && post.set('bookmarked', false));
const toggleBookmarkOnServer = () => {
return Discourse.ajax(`/t/${this.get('id')}${path}`, { type: 'PUT' }).then(() => {
this.toggleProperty('bookmarked');
if (bookmark && firstPost) {
firstPost.set('bookmarked', true);
return [firstPost.id];
}
}).catch(function(error) {
if (!bookmark && posts) {
const updated = [];
posts.forEach(post => {
if (post.get('bookmarked')) {
post.set('bookmarked', false);
updated.push(post.get('id'));
}
});
return updated;
}
return [];
}).catch(error => {
let showGenericError = true;
if (error && error.responseText) {
try {
@ -265,28 +268,26 @@ const Topic = RestModel.extend({
}
throw error;
}).finally(function() {
self.set("bookmarking", false);
});
}).finally(() => this.set('bookmarking', false));
};
let unbookmarkedPosts = [];
const unbookmarkedPosts = [];
if (!bookmark && posts) {
posts.forEach((post) => post.get('bookmarked') && unbookmarkedPosts.push(post));
posts.forEach(post => post.get('bookmarked') && unbookmarkedPosts.push(post));
}
if (unbookmarkedPosts.length > 1) {
return bootbox.confirm(
I18n.t("bookmarks.confirm_clear"),
I18n.t("no_value"),
I18n.t("yes_value"),
function (confirmed) {
if (confirmed) { return toggleBookmarkOnServer(); }
}
);
} else {
return toggleBookmarkOnServer();
}
return new Ember.RSVP.Promise(resolve => {
if (unbookmarkedPosts.length > 1) {
bootbox.confirm(
I18n.t("bookmarks.confirm_clear"),
I18n.t("no_value"),
I18n.t("yes_value"),
confirmed => confirmed ? toggleBookmarkOnServer().then(resolve) : resolve()
);
} else {
toggleBookmarkOnServer().then(resolve);
}
});
},
createInvite(emailOrUsername, groupNames) {
@ -390,25 +391,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'),

View File

@ -6,6 +6,7 @@ import DiscourseURL from 'discourse/lib/url';
import DiscourseLocation from 'discourse/lib/discourse-location';
import SearchService from 'discourse/services/search';
import { startTracking, default as TopicTrackingState } from 'discourse/models/topic-tracking-state';
import ScreenTrack from 'discourse/lib/screen-track';
function inject() {
const app = arguments[0],
@ -38,23 +39,29 @@ export default {
const currentUser = Discourse.User.current();
app.register('current-user:main', currentUser, { instantiate: false });
const tracking = TopicTrackingState.create({ messageBus, currentUser });
app.register('topic-tracking-state:main', tracking, { instantiate: false });
const topicTrackingState = TopicTrackingState.create({ messageBus, currentUser });
app.register('topic-tracking-state:main', topicTrackingState, { instantiate: false });
injectAll(app, 'topicTrackingState');
const site = Discourse.Site.current();
app.register('site:main', site, { instantiate: false });
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');
app.register('search-service:main', 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');
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');
app.register('location:discourse-location', DiscourseLocation);
@ -63,6 +70,6 @@ export default {
app.register('key-value-store:main', keyValueStore, { instantiate: false });
injectAll(app, 'keyValueStore');
startTracking(tracking);
startTracking(topicTrackingState);
}
};

View File

@ -37,12 +37,6 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, {
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) {
this.controllerFor('topic-entrance').send('show', data);
},

View File

@ -1,4 +1,3 @@
import ScreenTrack from 'discourse/lib/screen-track';
import { queryParams } from 'discourse/controllers/discovery-sortable';
// A helper to build a topic route for a filter
@ -69,7 +68,7 @@ export default function(filter, extras) {
model(data, transition) {
// attempt to stop early cause we need this to be called before .sync
ScreenTrack.current().stop();
this.screenTrack.stop();
const findOpts = filterQueryParams(data),
findExtras = { cached: this.isPoppedState(transition) };

View File

@ -1,4 +1,3 @@
import ScreenTrack from 'discourse/lib/screen-track';
import DiscourseURL from 'discourse/lib/url';
let isTransitioning = false,
@ -186,7 +185,7 @@ const TopicRoute = Discourse.Route.extend({
topicController.set('multiSelect', false);
topicController.unsubscribe();
this.controllerFor('composer').set('topic', null);
ScreenTrack.current().stop();
this.screenTrack.stop();
const headerController = this.controllerFor('header');
if (headerController) {
@ -215,8 +214,9 @@ const TopicRoute = Discourse.Route.extend({
controller.subscribe();
this.controllerFor('topic-progress').set('model', model);
// We reset screen tracking every time a topic is entered
ScreenTrack.current().start(model.get('id'), controller);
this.screenTrack.start(model.get('id'), controller);
}
});

View File

@ -4,7 +4,7 @@ export default Discourse.Route.extend({
// HACK: Something with the way the user card intercepts clicks seems to break how the
// transition into a user's activity works. This makes the back button work on mobile
// where there is no user card as well as desktop where there is.
if (Discourse.Mobile.mobileView) {
if (this.site.mobileView) {
this.replaceWith('userActivity');
} else {
this.transitionTo('userActivity');

View File

@ -29,7 +29,7 @@
{{#link-to 'user' user}}
{{avatar user imageSize="extra_large"}}
<div class="details clearfix">
{{poster-name post=user}}
<div class='username'>{{user.username}}</div>
</div>
{{/link-to}}
<div class='earned'>

View File

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

View File

@ -1,4 +1,3 @@
<div class='topic-avatar'>{{fa-icon icon}}</div>
<div class='small-action-desc'>
{{#if post}}
{{#if post.can_delete}}
@ -11,8 +10,4 @@
{{avatar post imageSize="small"}}
</a>
{{/if}}
<p>{{description}}</p>
{{#if post.cooked}}
<div class='custom-message'>{{{post.cooked}}}</div>
{{/if}}
</div>

View File

@ -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' replyCount=topic.replyCount readingTime=topic.estimatedReadingTime}}}</p>
{{else}}
<p>{{{i18n 'summary.description' replyCount=topic.replyCount}}}</p>
{{/if}}
<button class='btn btn-primary' {{action "toggleSummary"}}>{{i18n 'summary.enable'}}</button>
{{/if}}

View File

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

View File

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

View File

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

View File

@ -1,13 +0,0 @@
<article class='placeholder'>
<div class='row'>
<div class="topic-avatar">
<div class='placeholder-avatar'></div>
</div>
<div class='topic-body'>
<div class='placeholder-text'></div>
<div class='placeholder-text'></div>
<div class='placeholder-text'></div>
</div>
</div>
</article>

View File

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

View File

@ -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'}}&hellip;</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"}}

View File

@ -69,15 +69,39 @@
{{conditional-loading-spinner condition=model.postStream.loadingAbove}}
{{#unless model.postStream.loadingFilter}}
{{cloaked-collection itemViewClass="post"
defaultHeight="200"
content=postsToRender
slackRatio="15"
loadingHTML=""
preservesContext="true"
uncloakDefault="true"
offsetFixedTop="header"
offsetFixedBottom="#reply-control"}}
{{scrolling-post-stream
posts=postsToRender
canCreatePost=model.details.can_create_post
multiSelect=multiSelect
selectedPostsCount=selectedPostsCount
selectedQuery=selectedQuery
gaps=model.postStream.gaps
showFlags="showFlags"
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}}
</div>
<div id="topic-bottom"></div>

View File

@ -1,8 +1,8 @@
<section class='user-navigation'>
{{#unless mobileView}}
{{#if showNewPM}}
{{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}}
{{/if}}
{{#unless site.mobileView}}
{{#if showNewPM}}
{{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}}
{{/if}}
{{/unless}}
{{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=currentPath}}
@ -47,10 +47,10 @@
<i class="fa fa-list"></i>
</button>
{{#if mobileView}}
{{#if showNewPM}}
{{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}}
{{/if}}
{{#if site.mobileView}}
{{#if showNewPM}}
{{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}}
{{/if}}
{{/if}}
{{#if canArchive}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ export default Ember.View.extend({
$('#discourse-modal').modal('show');
// Focus on first element
if (!Discourse.Mobile.mobileView && this.get('focusInput')) {
if (!this.site.mobileView && this.get('focusInput')) {
Em.run.schedule('afterRender', () => this.$('input:first').focus());
}

View File

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

View File

@ -1 +0,0 @@
export default Ember.View.extend({ templateName: 'post-placeholder' });

View File

@ -1,383 +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 = href === lc.url;
// this might be an attachment
if (lc.internal && /^\/uploads\//.test(lc.url)) {
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;

View File

@ -1,3 +1,11 @@
// we don't want to deselect when we click on buttons that use it
function ignoreElements(e) {
const $target = $(e.target);
return $target.hasClass('quote-button') ||
$target.closest('.create').length ||
$target.closest('.reply-new').length;
}
export default Ember.View.extend({
classNames: ['quote-button'],
classNameBindings: ['visible'],
@ -45,11 +53,7 @@ export default Ember.View.extend({
.on("mousedown.quote-button", function(e) {
view.set('isMouseDown', true);
const $target = $(e.target);
// we don't want to deselect when we click on buttons that use it
if ($target.hasClass('quote-button') ||
$target.closest('.create').length ||
$target.closest('.reply-new').length) return;
if (ignoreElements(e)) { return; }
// deselects only when the user left click
// (allows anyone to `extend` their selection using shift+click)
@ -58,6 +62,8 @@ export default Ember.View.extend({
!e.shiftKey) controller.deselectText();
})
.on('mouseup.quote-button', function(e) {
if (ignoreElements(e)) { return; }
view.selectText(e.target, controller);
view.set('isMouseDown', false);
})

View File

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

View File

@ -82,7 +82,7 @@ export default Ember.View.extend({
$shareLink.css({top: "" + y + "px"});
if (!Discourse.Mobile.mobileView) {
if (!self.site.mobileView) {
$shareLink.css({left: "" + x + "px"});
}

View File

@ -6,7 +6,7 @@ export default ContainerView.extend({
@on('init')
createButtons() {
const mobileView = Discourse.Mobile.mobileView;
const mobileView = this.site.mobileView;
if (!mobileView && this.currentUser.get('staff')) {
const viewArgs = {action: 'showTopicAdminMenu', title: 'topic_admin_menu', icon: 'wrench', position: 'absolute'};

View File

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

View File

@ -76,7 +76,7 @@ export default Ember.View.extend({
_focusWhenOpened: function() {
// Don't focus on mobile or touch
if (Discourse.Mobile.mobileView || this.capabilities.isIOS) {
if (this.site.mobileView || this.capabilities.isIOS) {
return;
}

View File

@ -185,7 +185,7 @@ function highlight(postNumber) {
});
}
listenForViewEvent(TopicView, 'post:highlight', function(postNumber) {
listenForViewEvent(TopicView, 'post:highlight', postNumber => {
Ember.run.scheduleOnce('afterRender', null, highlight, postNumber);
});

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

Some files were not shown because too many files have changed in this diff Show More