diff --git a/.eslintrc b/.eslintrc index d2a13ae26..2b3ed3ba7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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, diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index 5b16593a8..553d70fb4 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -152,21 +152,18 @@ 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."); +}; + +['reopen', 'registerButton'].forEach(function(m) { RemovedObject.prototype[m] = methodMissing; }); + +['discourse/views/post', 'discourse/components/post-menu'].forEach(function(moduleName) { + define(moduleName, [], function() { return new RemovedObject(moduleName); }); +}); + diff --git a/app/assets/javascripts/discourse/adapters/post-reply-history.js.es6 b/app/assets/javascripts/discourse/adapters/post-reply-history.js.es6 new file mode 100644 index 000000000..335c22b6b --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/post-reply-history.js.es6 @@ -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 }; + }); + }, +}); diff --git a/app/assets/javascripts/discourse/adapters/post-reply.js.es6 b/app/assets/javascripts/discourse/adapters/post-reply.js.es6 new file mode 100644 index 000000000..f36299d00 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/post-reply.js.es6 @@ -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 }; + }); + }, +}); diff --git a/app/assets/javascripts/discourse/components/actions-summary.js.es6 b/app/assets/javascripts/discourse/components/actions-summary.js.es6 deleted file mode 100644 index 533438852..000000000 --- a/app/assets/javascripts/discourse/components/actions-summary.js.es6 +++ /dev/null @@ -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("
"); - - const renderLink = (dataAttribute, text) => { - buffer.push(` ${text}.`); - }; - - // TODO multi line expansion for flags - let iconsHtml = ""; - if (users.length) { - let postUrl; - users.forEach(function(u) { - const username = u.get('username'); - - iconsHtml += ``; - 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 += ""; - }); - - 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("
"); - }); - } - - const post = this.get('post'); - if (post.get('deleted')) { - buffer.push("
" + - iconHTML('fa-trash-o') + ' ' + - Discourse.Utilities.tinyAvatar(post.get('postDeletedBy.avatar_template'), {title: post.get('postDeletedBy.username')}) + - autoUpdatingRelativeAge(new Date(post.get('postDeletedAt'))) + - "
"); - } - - buffer.push("
"); - }, - - 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; - } -}); diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index ddc86134c..ff6193afa 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -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) { diff --git a/app/assets/javascripts/discourse/components/mount-widget.js.es6 b/app/assets/javascripts/discourse/components/mount-widget.js.es6 new file mode 100644 index 000000000..d23485964 --- /dev/null +++ b/app/assets/javascripts/discourse/components/mount-widget.js.es6 @@ -0,0 +1,47 @@ +import { diff, patch } from 'virtual-dom'; +import { WidgetClickHook } from 'discourse/widgets/click-hook'; + +export default Ember.Component.extend({ + _tree: null, + _rootNode: null, + _timeout: null, + _widgetClass: null, + + init() { + this._super(); + this._widgetClass = this.container.lookupFactory(`widget:${this.get('widget')}`); + }, + + didInsertElement() { + WidgetClickHook.setupDocumentCallback(); + + this._rootNode = document.createElement('div'); + this.element.appendChild(this._rootNode); + this._timeout = Ember.run.scheduleOnce('render', this, this.rerenderWidget); + }, + + willDestroyElement() { + Ember.run.cancel(this._timeout); + }, + + queueRerender() { + Ember.run.scheduleOnce('render', this, this.rerenderWidget); + }, + + rerenderWidget() { + Ember.run.cancel(this._timeout); + if (this._rootNode) { + const t0 = new Date().getTime(); + + const opts = { model: this.get('model') }; + const newTree = new this._widgetClass(this.get('args'), this.container, opts); + + newTree._emberView = this; + const patches = diff(this._tree || this._rootNode, newTree); + this._rootNode = patch(this._rootNode, patches); + this._tree = newTree; + console.log('render: ', new Date().getTime() - t0); + } + } + +}); diff --git a/app/assets/javascripts/discourse/components/post-gap.js.es6 b/app/assets/javascripts/discourse/components/post-gap.js.es6 deleted file mode 100644 index 159d047f0..000000000 --- a/app/assets/javascripts/discourse/components/post-gap.js.es6 +++ /dev/null @@ -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; - } -}); diff --git a/app/assets/javascripts/discourse/components/post-gutter.js.es6 b/app/assets/javascripts/discourse/components/post-gutter.js.es6 deleted file mode 100644 index 9889184d1..000000000 --- a/app/assets/javascripts/discourse/components/post-gutter.js.es6 +++ /dev/null @@ -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("'); - } - - if (this.get('canReplyAsNewTopic')) { - buffer.push(`${iconHTML('plus')}${I18n.t('post.reply_as_new_topic')}`); - } - }, - - 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; - } -}); diff --git a/app/assets/javascripts/discourse/components/post-menu.js.es6 b/app/assets/javascripts/discourse/components/post-menu.js.es6 deleted file mode 100644 index ef63cd9d2..000000000 --- a/app/assets/javascripts/discourse/components/post-menu.js.es6 +++ /dev/null @@ -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(""); -}; - -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(""); - }, - - // 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(""); - }, - - 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('
'); - visibleButtons.forEach((b) => b.render(buffer)); - buffer.push("
"); - }, - - 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: "
"}); - }, - - 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 = '
' + - '

' + I18n.t('admin_title') + '

' + - '' + - '
'; - - 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; diff --git a/app/assets/javascripts/discourse/components/poster-name.js.es6 b/app/assets/javascripts/discourse/components/poster-name.js.es6 deleted file mode 100644 index 7f213f7c0..000000000 --- a/app/assets/javascripts/discourse/components/poster-name.js.es6 +++ /dev/null @@ -1,77 +0,0 @@ -import { setting } from 'discourse/lib/computed'; - -const PosterNameComponent = Em.Component.extend({ - classNames: ['names', 'trigger-user-card'], - displayNameOnPosts: setting('display_name_on_posts'), - - // sanitize name for comparison - sanitizeName(name){ - return name.toLowerCase().replace(/[\s_-]/g,''); - }, - - render(buffer) { - const post = this.get('post'); - - if (post) { - const username = post.get('username'), - primaryGroupName = post.get('primary_group_name'), - url = post.get('usernameUrl'); - - var linkClass = 'username', - name = post.get('name'); - - if (post.get('staff')) { linkClass += ' staff'; } - if (post.get('admin')) { linkClass += ' admin'; } - if (post.get('moderator')) { linkClass += ' moderator'; } - if (post.get('new_user')) { linkClass += ' new-user'; } - - if (!Em.isEmpty(primaryGroupName)) { - linkClass += ' ' + primaryGroupName; - } - // Main link - buffer.push("" + username + ""); - - // Add a glyph if we have one - const glyph = this.posterGlyph(post); - if (!Em.isEmpty(glyph)) { - buffer.push(glyph); - } - buffer.push(""); - - // Are we showing full names? - if (name && this.get('displayNameOnPosts') && (this.sanitizeName(name) !== this.sanitizeName(username))) { - name = Discourse.Utilities.escapeExpression(name); - buffer.push("" + name + ""); - } - - // User titles - let title = post.get('user_title'); - if (!Em.isEmpty(title)) { - - title = Discourse.Utilities.escapeExpression(title); - buffer.push(''); - if (Em.isEmpty(primaryGroupName)) { - buffer.push(title); - } else { - buffer.push("" + title + ""); - } - buffer.push(""); - } - - 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 ''; - } - } -}); - -// Support for event triggering -PosterNameComponent.reopenClass(Em.Evented); - -export default PosterNameComponent; diff --git a/app/assets/javascripts/discourse/components/private-message-map.js.es6 b/app/assets/javascripts/discourse/components/private-message-map.js.es6 deleted file mode 100644 index 0d01910c7..000000000 --- a/app/assets/javascripts/discourse/components/private-message-map.js.es6 +++ /dev/null @@ -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'); - } - } - -}); diff --git a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 new file mode 100644 index 000000000..a8ae4b7af --- /dev/null +++ b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 @@ -0,0 +1,145 @@ +import { keyDirty } from 'discourse/widgets/widget'; +import MountWidget from 'discourse/components/mount-widget'; + +function findTopView($posts, viewportTop, min, max) { + if (max < min) { return min; } + + while(max>min){ + const mid = Math.floor((min + max) / 2); + const $post = $($posts[mid]); + const viewBottom = $post.position().top + $post.height(); + + if (viewBottom > viewportTop) { + max = mid-1; + } else { + min = mid+1; + } + } + + return min; +} + +export default MountWidget.extend({ + widget: 'post-stream', + _topVisible: null, + _bottomVisible: null, + + args: Ember.computed(function() { + return this.getProperties('posts', + 'canCreatePost', + 'multiSelect', + 'selectedQuery', + 'selectedPostsCount', + 'searchService'); + }).volatile(), + + scrolled() { + const $w = $(window); + const windowHeight = window.innerHeight ? window.innerHeight : $w.height(); + const slack = Math.round(windowHeight * 15); + const onscreen = []; + + let windowTop = $w.scrollTop(); + + const $posts = this.$('article.boxed'); + const viewportTop = windowTop - slack; + const topView = findTopView($posts, viewportTop, 0, $posts.length-1); + + let windowBottom = windowTop + windowHeight; + let viewportBottom = windowBottom + slack; + + const bodyHeight = $('body').height(); + if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } + if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; } + + let bottomView = topView; + while (bottomView < $posts.length) { + const post = $posts[bottomView]; + const $post = $(post); + + if (!$post) { break; } + + const viewTop = $post.offset().top; + const viewBottom = viewTop + $post.height(); + + if (viewTop > viewportBottom) { break; } + + if (viewBottom > windowTop && viewTop <= windowBottom) { + onscreen.push(bottomView); + } + + bottomView++; + } + + const posts = this.posts; + if (onscreen.length) { + + const refresh = () => this.queueRerender(); + const first = posts.objectAt(onscreen[0]); + if (this._topVisible !== first) { + this._topVisible = first; + const $body = $('body'); + const elem = $posts[onscreen[0]]; + const elemId = elem.id; + const $elem = $(elem); + const elemPos = $elem.position(); + const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0; + + const topRefresh = () => { + refresh(); + + Ember.run.next(() => { + const $refreshedElem = $(elemId); + + // Quickly going back might mean the element is destroyed + const position = $refreshedElem.position(); + if (position && position.top) { + $('html, body').scrollTop(position.top + distToElement); + } + }); + }; + this.sendAction('topVisibleChanged', { post: first, refresh: topRefresh }); + } + + const last = posts.objectAt(onscreen[onscreen.length-1]); + if (this._bottomVisible !== last) { + this._bottomVisible = last; + this.sendAction('bottomVisibleChanged', { post: last, refresh }); + } + } else { + this._topVisible = null; + this._bottomVisible = null; + } + + const onscreenPostNumbers = onscreen.map(idx => posts.objectAt(idx).post_number); + this.screenTrack.setOnscreen(onscreenPostNumbers); + }, + + _scrollTriggered() { + Ember.run.scheduleOnce('afterRender', this, this.scrolled); + }, + + didInsertElement() { + this._super(); + const debouncedScroll = () => Ember.run.debounce(this, this._scrollTriggered, 10); + + $(document).bind('touchmove.post-stream', debouncedScroll); + $(window).bind('scroll.post-stream', debouncedScroll); + this._scrollTriggered(); + + this.appEvents.on('post-stream:refresh', postId => { + if (postId) { + keyDirty(`post-${postId}`); + } + this.queueRerender(); + }); + }, + + willDestroyElement() { + this._super(); + $(document).unbind('touchmove.post-stream'); + $(window).unbind('scroll.post-stream'); + this.appEvents.off('post-stream:refresh'); + } + +}); diff --git a/app/assets/javascripts/discourse/components/small-action.js.es6 b/app/assets/javascripts/discourse/components/small-action.js.es6 index 567436348..b66024987 100644 --- a/app/assets/javascripts/discourse/components/small-action.js.es6 +++ b/app/assets/javascripts/discourse/components/small-action.js.es6 @@ -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 ? `@${username}` : ""; + 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 ? `@${u}` : ""; - 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')); diff --git a/app/assets/javascripts/discourse/components/time-gap.js.es6 b/app/assets/javascripts/discourse/components/time-gap.js.es6 deleted file mode 100644 index 9e410c4b7..000000000 --- a/app/assets/javascripts/discourse/components/time-gap.js.es6 +++ /dev/null @@ -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(), -}); diff --git a/app/assets/javascripts/discourse/components/toggle-summary.js.es6 b/app/assets/javascripts/discourse/components/toggle-summary.js.es6 deleted file mode 100644 index 45dad2660..000000000 --- a/app/assets/javascripts/discourse/components/toggle-summary.js.es6 +++ /dev/null @@ -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(); - } - } -}); diff --git a/app/assets/javascripts/discourse/components/topic-map.js.es6 b/app/assets/javascripts/discourse/components/topic-map.js.es6 deleted file mode 100644 index b09d1a2d8..000000000 --- a/app/assets/javascripts/discourse/components/topic-map.js.es6 +++ /dev/null @@ -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); - } - } -}); diff --git a/app/assets/javascripts/discourse/components/topic-participant.js.es6 b/app/assets/javascripts/discourse/components/topic-participant.js.es6 deleted file mode 100644 index e8e58ea34..000000000 --- a/app/assets/javascripts/discourse/components/topic-participant.js.es6 +++ /dev/null @@ -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')); - } - } - } -}); diff --git a/app/assets/javascripts/discourse/components/who-liked.js.es6 b/app/assets/javascripts/discourse/components/who-liked.js.es6 deleted file mode 100644 index 412dfecde..000000000 --- a/app/assets/javascripts/discourse/components/who-liked.js.es6 +++ /dev/null @@ -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("
"); - let iconsHtml = ""; - users.forEach(function(u) { - iconsHtml += ""; - iconsHtml += Discourse.Utilities.avatarImg({ - size: 'small', - avatarTemplate: u.get('avatar_template'), - title: u.get('username') - }); - iconsHtml += ""; - }); - buffer.push(I18n.t('post.actions.people.like',{icons: iconsHtml})); - buffer.push("
"); - } else { - buffer.push(""); - } - } -}); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 88e6c532b..b7c1ae0d0 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -289,6 +289,7 @@ export default Ember.Controller.extend({ self.destroyDraft(); } + self.appEvents.trigger('post-stream:refresh'); self.close(); const currentUser = Discourse.User.current(); @@ -587,14 +588,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"), diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 2fe9e6ee7..b4bb45fd0 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -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,60 @@ 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'); + + this.set('model.currentPost', post.get('post_number')); + + if (post.get('post_number') === 1) { return; } + + if (firstLoadedPost && firstLoadedPost === post) { + postStream.prependMore().then(() => refresh()); + } + }, + + // Called the the bottommost visible post on the page changes. + bottomVisibleChanged(event) { + const { post, refresh } = event; + + const postStream = this.get('model.postStream'); + const lastLoadedPost = postStream.get('posts.lastObject'); + + this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post)); + + if (lastLoadedPost && lastLoadedPost === post) { + postStream.appendMore().then(() => refresh()); + } + }, + + toggleSummary() { + return this.get('model.postStream').toggleSummary(); + }, + + removeAllowedUser(user) { + return this.get('model.details').removeAllowedUser(user); + }, + showTopicAdminMenu() { this.set('adminMenuVisible', true); }, @@ -113,7 +165,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this.deleteTopic(); }, - archiveMessage() { const topic = this.get('model'); topic.archiveMessage().then(()=>{ @@ -176,8 +227,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 +260,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,7 +297,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, toggleBookmark(post) { - if (!Discourse.User.current()) { + if (!this.currentUser) { alert(I18n.t("bookmarks.not_bookmarked")); return; } @@ -261,18 +313,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'); }, deselectAll() { this.get('selectedPosts').clear(); this.get('selectedReplies').clear(); this.set('allPostsSelected', false); + this.appEvents.trigger('post-stream:refresh'); }, toggleParticipant(user) { @@ -293,6 +347,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { toggleMultiSelect() { this.toggleProperty('multiSelect'); + this.appEvents.trigger('post-stream:refresh'); }, finishedEditingTopic() { @@ -447,18 +502,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }); }, - expandFirstPost(post) { - const self = this; - this.set('loadingExpanded', true); - post.expand().then(function() { - self.set('firstPostExpanded', true); - }).catch(function(error) { - bootbox.alert($.parseJSON(error.responseText).errors); - }).finally(function() { - self.set('loadingExpanded', false); - }); - }, - retryLoading() { const self = this; self.set('retrying', true); @@ -470,22 +513,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 +541,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 +636,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 +647,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; @@ -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"); diff --git a/app/assets/javascripts/discourse/ember/resolver.js.es6 b/app/assets/javascripts/discourse/ember/resolver.js.es6 index c8c2a3eee..85d3ce61a 100644 --- a/app/assets/javascripts/discourse/ember/resolver.js.es6 +++ b/app/assets/javascripts/discourse/ember/resolver.js.es6 @@ -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); }, diff --git a/app/assets/javascripts/discourse/helpers/as-hash.js.es6 b/app/assets/javascripts/discourse/helpers/as-hash.js.es6 new file mode 100644 index 000000000..c5c67a64e --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/as-hash.js.es6 @@ -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; +} diff --git a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 index 456f87605..fa35b02d9 100644 --- a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 +++ b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 @@ -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 = "" + domain + ""); - } - } - } -}); diff --git a/app/assets/javascripts/discourse/helpers/node.js.es6 b/app/assets/javascripts/discourse/helpers/node.js.es6 new file mode 100644 index 000000000..3f34a804c --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/node.js.es6 @@ -0,0 +1,25 @@ +import { h } from 'virtual-dom'; +import { relativeAge, longDate } from 'discourse/lib/formatter'; +import { number } from 'discourse/lib/formatter'; + +export function dateNode(dt) { + if (typeof dt === "string") { dt = new Date(dt); } + if (dt) { + return h('span', { attributes: { title: longDate(dt) } }, relativeAge(dt)); + } +} + +export function numberNode(num, opts) { + opts = opts || {}; + num = parseInt(num, 10); + if (isNaN(num)) { num = 0; } + + const numString = num.toString(); + const attributes = { }; + const formatted = number(num); + if (formatted !== numString) { + attributes.title = numString; + } + + return h('span.number', { className: opts.className, attributes }, formatted); +} diff --git a/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 b/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 index fad9e4563..4074d293f 100644 --- a/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 +++ b/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 @@ -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(); } diff --git a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 index a126bf783..48e7ef4aa 100644 --- a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 +++ b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 @@ -1,5 +1,3 @@ -import CloakedCollectionView from 'discourse/views/cloaked-collection'; - /** @module Discourse */ @@ -221,36 +219,4 @@ const DiscourseLocation = Ember.Object.extend({ }); -/** - Since we're using pushState/replaceState let's add extra hooks to cloakedView to - eject itself when the popState occurs. This results in better back button - behavior. -**/ -CloakedCollectionView.reopen({ - _watchForPopState: function() { - const self = this, - cb = function() { - // Sam: This is a hack, but a very important one - // Due to the way we use replace state the back button works strangely - // - // If you visit a topic from the front page, scroll a bit around and then hit back - // you notice that first the page scrolls a bit (within the topic) and then it goes back - // this transition is jarring and adds uneeded rendering costs. - // - // To repro comment the hack out and wack a debugger statement here and in - // topic_route deactivate - $('.posts,#topic-title').hide(); - self.cleanUp(); - self.set('controller.model.postStream.loaded', false); - }; - this.set('_callback', cb); - popstateCallbacks.addObject(cb); - }.on('didInsertElement'), - - _disbandWatcher: function() { - popstateCallbacks.removeObject(this.get('_callback')); - this.set('_callback', null); - }.on('willDestroyElement') -}); - export default DiscourseLocation; diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index b7a7ece91..8b51a87fa 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -12,6 +12,5 @@ export function decorateCooked(container, cb) { 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); } diff --git a/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 index db7f1238a..d6d62ee1d 100644 --- a/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 +++ b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 @@ -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, diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 93e584f23..3506b06d6 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -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(); diff --git a/app/assets/javascripts/discourse/lib/screen-track.js.es6 b/app/assets/javascripts/discourse/lib/screen-track.js.es6 index 2bc937387..60704ac4b 100644 --- a/app/assets/javascripts/discourse/lib/screen-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/screen-track.js.es6 @@ -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; +} diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6 new file mode 100644 index 000000000..55ca19041 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -0,0 +1,191 @@ +function actionDescription(action, acted, count) { + if (acted) { + if (count <= 1) { + return I18n.t(`post.actions.by_you.${action}`); + } else { + return I18n.t(`post.actions.by_you_and_others.${action}`, { count: count - 1 }); + } + } else { + return I18n.t(`post.actions.by_others.${action}`, { count }); + } +} + +export function transformBasicPost(post) { + // Note: it can be dangerous to not use `get` in Ember code, but this is significantly + // faster and has tests to confirm it works. We only call `get` when the property is a CP + return { + id: post.id, + hidden: post.hidden, + deleted: post.get('deleted'), + deleted_at: post.deleted_at, + user_deleted: post.user_deleted, + isDeleted: post.deleted_at || post.user_deleted, + deletedByAvatarTemplate: null, + deletedByUsername: null, + primary_group_name: post.primary_group_name, + wiki: post.wiki, + firstPost: post.post_number === 1, + post_number: post.post_number, + cooked: post.cooked, + via_email: post.via_email, + user_id: post.user_id, + usernameUrl: Discourse.getURL(`/users/${post.username}`), + username: post.username, + avatar_template: post.avatar_template, + bookmarked: post.bookmarked, + yours: post.yours, + shareUrl: post.get('shareUrl'), + staff: post.staff, + admin: post.admin, + moderator: post.moderator, + new_user: post.trust_level === 0, + name: post.name, + user_title: post.user_title, + created_at: post.created_at, + updated_at: post.updated_at, + canDelete: post.can_delete, + canRecover: post.can_recover, + canEdit: post.can_edit, + canFlag: !Ember.isEmpty(post.flagsAvailable), + version: post.version, + canRecoverTopic: false, + canDeletedTopic: false, + canViewEditHistory: post.can_view_edit_history, + canWiki: post.can_wiki, + showLike: false, + liked: false, + canToggleLike: false, + likeCount: false, + actionsSummary: null, + read: post.read, + replyToUsername: null, + replyToAvatarTemplate: null, + reply_to_post_number: post.reply_to_post_number, + cooked_hidden: !!post.cooked_hidden, + expandablePost: false, + replyCount: post.reply_count, + }; +} + + +export default function transformPost(currentUser, site, post, prevPost, nextPost) { + // Note: it can be dangerous to not use `get` in Ember code, but this is significantly + // faster and has tests to confirm it works. We only call `get` when the property is a CP + const postType = post.post_type; + const postTypes = site.post_types; + const topic = post.topic; + const details = topic.get('details'); + + const postAtts = transformBasicPost(post); + + postAtts.topicId = topic.id; + postAtts.topicOwner = details.created_by.id === post.user_id; + postAtts.post_type = postType; + postAtts.via_email = post.via_email; + postAtts.isModeratorAction = postType === postTypes.moderator_action; + postAtts.isWhisper = postType === postTypes.whisper; + postAtts.isSmallAction = postType === postTypes.small_action; + postAtts.canBookmark = !!currentUser; + postAtts.canManage = currentUser && currentUser.get('canManageTopic'); + postAtts.canViewRawEmail = currentUser && (currentUser.id === post.user_id || currentUser.staff); + postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic; + postAtts.isWarning = topic.is_warning; + postAtts.links = post.get('internalLinks'); + postAtts.replyDirectlyBelow = nextPost && nextPost.reply_to_post_number === post.post_number; + postAtts.replyDirectlyAbove = prevPost && post.reply_to_post_number === prevPost.post_number; + postAtts.linkCounts = post.link_counts; + postAtts.actionCode = post.action_code; + postAtts.actionCodeWho = post.action_code_who; + + const showPMMap = topic.archetype === 'private_message' && post.post_number === 1; + if (showPMMap) { + postAtts.showPMMap = true; + postAtts.allowedGroups = details.allowed_groups; + postAtts.allowedUsers = details.allowed_users; + postAtts.canRemoveAllowedUsers = details.can_remove_allowed_users; + postAtts.canInvite = details.can_invite_to; + } + + const showTopicMap = showPMMap || (post.post_number === 1 && topic.archetype === 'regular' && topic.posts_count > 1); + if (showTopicMap) { + postAtts.showTopicMap = true; + postAtts.topicUrl = topic.get('url'); + postAtts.topicCreatedAt = topic.created_at; + postAtts.createdByUsername = details.created_by.username; + postAtts.createdByAvatarTemplate = details.created_by.avatar_template; + + postAtts.lastPostUrl = topic.get('lastPostUrl'); + postAtts.lastPostUsername = details.last_poster.username; + postAtts.lastPostAvatarTemplate = details.last_poster.avatar_template; + postAtts.lastPostAt = topic.last_posted_at; + + postAtts.topicReplyCount = topic.get('replyCount'); + postAtts.topicViews = topic.views; + postAtts.topicViewsHeat = topic.get('viewsHeat'); + + postAtts.participantCount = topic.participant_count; + postAtts.topicLikeCount = topic.like_count; + postAtts.topicLinks = details.links; + if (postAtts.topicLinks) { + postAtts.topicLinkLength = details.links.length; + } + postAtts.topicPostsCount = topic.posts_count; + + postAtts.participants = details.participants; + + const postStream = topic.get('postStream'); + postAtts.userFilters = postStream.userFilters; + postAtts.topicSummaryEnabled = postStream.summary; + postAtts.topicWordCount = topic.word_count; + postAtts.hasTopicSummary = topic.has_summary; + } + + if (postAtts.isDeleted) { + postAtts.deletedByAvatarTemplate = post.get('postDeletedBy.avatar_template'); + postAtts.deletedByUsername = post.get('postDeletedBy.username'); + } + + const replyToUser = post.get('reply_to_user'); + if (replyToUser) { + postAtts.replyToUsername = replyToUser.username; + postAtts.replyToAvatarTemplate = replyToUser.avatar_template; + } + + if (post.actions_summary) { + postAtts.actionsSummary = post.actions_summary.filter(a => { + return a.actionType.name_key !== 'like' && a.count > 0; + }).map(a => { + const acted = a.acted; + const action = a.actionType.name_key; + const count = a.count; + + return { id: a.id, + postId: post.id, + action, + acted, + count, + canUndo: a.can_undo, + canDeferFlags: a.can_defer_flags, + description: actionDescription(action, acted, count) }; + }); + } + + const likeAction = post.likeAction; + if (likeAction) { + postAtts.showLike = true; + postAtts.liked = likeAction.acted; + postAtts.canToggleLike = likeAction.get('canToggle'); + postAtts.likeCount = likeAction.count; + } + + if (postAtts.post_number === 1) { + postAtts.canRecoverTopic = topic.deleted_at && details.can_recover; + postAtts.canDeleteTopic = !topic.deleted_at && details.can_delete; + postAtts.expandablePost = topic.expandable_first_post; + } else { + postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover; + postAtts.canDelete = !postAtts.isDeleted && postAtts.canDelete; + } + + return postAtts; +} diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index c35cffba3..3c76d4f51 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -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(); - }); }, diff --git a/app/assets/javascripts/discourse/models/action-summary.js.es6 b/app/assets/javascripts/discourse/models/action-summary.js.es6 index a8f75cd53..a39d78fb5 100644 --- a/app/assets/javascripts/discourse/models/action-summary.js.es6 +++ b/app/assets/javascripts/discourse/models/action-summary.js.es6 @@ -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)); } }); diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 02511e01f..2b5136ccc 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -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, @@ -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)); @@ -471,10 +454,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 +467,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 +528,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; }, /** diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index ff638cef6..8565cfc7d 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -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 }); }); }, diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index a7f4edcf9..52246ff34 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -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"; }, diff --git a/app/assets/javascripts/discourse/models/topic-details.js.es6 b/app/assets/javascripts/discourse/models/topic-details.js.es6 index c40213a72..a92174bc9 100644 --- a/app/assets/javascripts/discourse/models/topic-details.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-details.js.es6 @@ -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)); }); } diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 24d0638a5..247825184 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -224,13 +224,6 @@ 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; } this.set("bookmarking", true); @@ -390,25 +383,6 @@ const Topic = RestModel.extend({ }); }, - // Is the reply to a post directly below it? - isReplyDirectlyBelow(post) { - const posts = this.get('postStream.posts'); - const postNumber = post.get('post_number'); - if (!posts) return; - - const postBelow = posts[posts.indexOf(post) + 1]; - - // If the post directly below's reply_to_post_number is our post number or we are quoted, - // it's considered directly below. - // - // TODO: we don't carry information about quoting, this leaves this code fairly fragile - // instead we should start shipping quote meta data with posts, but this will add at least - // 1 query to the topics page - // - return postBelow && (postBelow.get('reply_to_post_number') === postNumber || - postBelow.get('cooked').indexOf('data-post="'+ postNumber + '"') >= 0 - ); - }, hasExcerpt: Em.computed.notEmpty('excerpt'), diff --git a/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 b/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 index 7d66de697..96c4cea32 100644 --- a/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 @@ -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); } }; diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 8c1637b7a..54892bcd9 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -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); }, diff --git a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 index 890f0729f..990a88215 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -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) }; diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index a4a6fa58c..6e17a41b5 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -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); } }); diff --git a/app/assets/javascripts/discourse/templates/components/private-message-map.hbs b/app/assets/javascripts/discourse/templates/components/private-message-map.hbs deleted file mode 100644 index eb2d2dd5f..000000000 --- a/app/assets/javascripts/discourse/templates/components/private-message-map.hbs +++ /dev/null @@ -1,24 +0,0 @@ -

{{fa-icon 'envelope'}} {{i18n 'private_message_info.title'}}

-
- {{#each details.allowed_groups as |ag|}} -
- {{fa-icon 'users'}} {{#link-to "group.index" ag.name}}{{unbound ag.name}}{{/link-to}} -
- {{/each}} - {{#each details.allowed_users as |au|}} -
- {{#user-link user=au}} - {{avatar au imageSize="small"}} - {{unbound au.username}} - {{/user-link}} - {{#if details.can_remove_allowed_users}} - {{fa-icon "times"}} - {{/if}} -
- {{/each}} -
-{{#if details.can_invite_to}} -
- -
-{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/small-action.hbs b/app/assets/javascripts/discourse/templates/components/small-action.hbs index 091f0e07d..821ed4ff9 100644 --- a/app/assets/javascripts/discourse/templates/components/small-action.hbs +++ b/app/assets/javascripts/discourse/templates/components/small-action.hbs @@ -1,4 +1,3 @@ -
{{fa-icon icon}}
{{#if post}} {{#if post.can_delete}} @@ -11,8 +10,4 @@ {{avatar post imageSize="small"}} {{/if}} -

{{description}}

- {{#if post.cooked}} -
{{{post.cooked}}}
- {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/components/toggle-summary.hbs b/app/assets/javascripts/discourse/templates/components/toggle-summary.hbs deleted file mode 100644 index 6eed02a1c..000000000 --- a/app/assets/javascripts/discourse/templates/components/toggle-summary.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if postStream.summary}} -

{{{i18n 'summary.enabled_description'}}}

- -{{else}} - {{#if topic.estimatedReadingTime}} -

{{{i18n 'summary.description_time' count=topic.posts_count readingTime=topic.estimatedReadingTime}}}

- {{else}} -

{{{i18n 'summary.description' count=topic.posts_count}}}

- {{/if}} - - -{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/topic-participant.hbs b/app/assets/javascripts/discourse/templates/components/topic-participant.hbs deleted file mode 100644 index 0389b002f..000000000 --- a/app/assets/javascripts/discourse/templates/components/topic-participant.hbs +++ /dev/null @@ -1,6 +0,0 @@ - - {{#if showPostCount}} - {{unbound participant.post_count}} - {{/if}} - {{avatar participant imageSize="medium"}} - diff --git a/app/assets/javascripts/discourse/templates/embedded-post.hbs b/app/assets/javascripts/discourse/templates/embedded-post.hbs deleted file mode 100644 index 03bfc30a2..000000000 --- a/app/assets/javascripts/discourse/templates/embedded-post.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
-
- {{raw "post/poster-avatar" post=this classNames="main-avatar"}} -
-
- -
- {{{unbound cooked}}} -
-
-
diff --git a/app/assets/javascripts/discourse/templates/modal/archetype_options.hbs b/app/assets/javascripts/discourse/templates/modal/archetype_options.hbs deleted file mode 100644 index d8c949d9b..000000000 --- a/app/assets/javascripts/discourse/templates/modal/archetype_options.hbs +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/assets/javascripts/discourse/templates/post-small-action.hbs b/app/assets/javascripts/discourse/templates/post-small-action.hbs deleted file mode 100644 index 42c11c524..000000000 --- a/app/assets/javascripts/discourse/templates/post-small-action.hbs +++ /dev/null @@ -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"}} diff --git a/app/assets/javascripts/discourse/templates/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs deleted file mode 100644 index 1606d6d6f..000000000 --- a/app/assets/javascripts/discourse/templates/post.hbs +++ /dev/null @@ -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}} - -
- {{view 'reply-history' content=replyHistory}} -
- -
-
- -
- {{#if userDeleted}} - - {{else}} - {{raw "post/poster-avatar" post=this classNames="main-avatar"}} - {{/if}} -
- {{plugin-outlet "poster-avatar-bottom"}} -
- -
- - -
- - -
- - -
-
- {{{cooked}}} - {{plugin-outlet "post-after-cooked"}} - {{#if firstPost}} - {{plugin-outlet "topic-after-cooked"}} - {{/if}} -
- {{#if cooked_hidden}} - {{i18n 'post.show_hidden'}} - {{/if}} - {{#if view.showExpandButton}} - {{#if controller.loadingExpanded}} - - {{else}} - - {{/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}} -
- - {{who-liked users=view.likedUsers}} - {{#if replies}} -
- {{#each reply in replies}} - {{view 'embedded-post' content=reply}} - {{/each}} -
- {{/if}} - - {{actions-summary post=this}} - {{view 'topic-map-container' post=this topic=controller.model}} -
- - {{post-gutter post=this - links=internalLinks - canReplyAsNewTopic=topic.details.can_reply_as_new_topic - newTopicAction="replyAsNewTopic"}} -
- -
- -{{post-gap post=this postStream=controller.model.postStream before="false"}} -{{plugin-outlet "post-bottom"}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 93358cf1b..d6083d0f3 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -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}}
diff --git a/app/assets/javascripts/discourse/views/archetype-options.js.es6 b/app/assets/javascripts/discourse/views/archetype-options.js.es6 deleted file mode 100644 index 8cf0c4bd3..000000000 --- a/app/assets/javascripts/discourse/views/archetype-options.js.es6 +++ /dev/null @@ -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); - } - - }); - } -}); diff --git a/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 b/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 deleted file mode 100644 index 4bf5f9f2f..000000000 --- a/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 +++ /dev/null @@ -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; j0){ - 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; diff --git a/app/assets/javascripts/discourse/views/cloaked.js.es6 b/app/assets/javascripts/discourse/views/cloaked.js.es6 deleted file mode 100644 index ae96413c7..000000000 --- a/app/assets/javascripts/discourse/views/cloaked.js.es6 +++ /dev/null @@ -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') -}); - diff --git a/app/assets/javascripts/discourse/views/embedded-post.js.es6 b/app/assets/javascripts/discourse/views/embedded-post.js.es6 deleted file mode 100644 index e6d85a85c..000000000 --- a/app/assets/javascripts/discourse/views/embedded-post.js.es6 +++ /dev/null @@ -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') -}); diff --git a/app/assets/javascripts/discourse/views/grouped.js.es6 b/app/assets/javascripts/discourse/views/grouped.js.es6 deleted file mode 100644 index 988c16612..000000000 --- a/app/assets/javascripts/discourse/views/grouped.js.es6 +++ /dev/null @@ -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') -}); diff --git a/app/assets/javascripts/discourse/views/option-boolean.js.es6 b/app/assets/javascripts/discourse/views/option-boolean.js.es6 deleted file mode 100644 index 813677edc..000000000 --- a/app/assets/javascripts/discourse/views/option-boolean.js.es6 +++ /dev/null @@ -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') -}); diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 deleted file mode 100644 index 21535c72d..000000000 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ /dev/null @@ -1,381 +0,0 @@ -import ScreenTrack from 'discourse/lib/screen-track'; -import { number } from 'discourse/lib/formatter'; -import DiscourseURL from 'discourse/lib/url'; -import { default as computed, on } from 'ember-addons/ember-computed-decorators'; -import { fmt } from 'discourse/lib/computed'; -import { isValidLink } from 'discourse/lib/click-track'; - -const DAY = 60 * 50 * 1000; - -const PostView = Discourse.GroupedView.extend(Ember.Evented, { - classNames: ['topic-post', 'clearfix'], - classNameBindings: ['needsModeratorClass:moderator:regular', - 'selected', - 'post.hidden:post-hidden', - 'post.deleted:deleted', - 'post.topicOwner:topic-owner', - 'groupNameClass', - 'post.wiki:wiki', - 'whisper'], - - post: Ember.computed.alias('content'), - postElementId: fmt('post.post_number', 'post_%@'), - likedUsers: null, - - @on('init') - initLikedUsers() { - this.set('likedUsers', []); - }, - - @computed('post.post_type') - whisper(postType) { - return postType === this.site.get('post_types.whisper'); - }, - - templateName: function() { - return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post'; - }.property('post.post_type'), - - historyHeat: function() { - const updatedAt = this.get('post.updated_at'); - if (!updatedAt) { return; } - - // Show heat on age - const rightNow = new Date().getTime(), - updatedAtDate = new Date(updatedAt).getTime(); - - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_low)) return 'heatmap-high'; - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_medium)) return 'heatmap-med'; - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_high)) return 'heatmap-low'; - }.property('post.updated_at'), - - needsModeratorClass: function() { - return (this.get('post.post_type') === this.site.get('post_types.moderator_action')) || - (this.get('post.topic.is_warning') && this.get('post.firstPost')); - }.property('post.post_type'), - - groupNameClass: function() { - const primaryGroupName = this.get('post.primary_group_name'); - if (primaryGroupName) { - return "group-" + primaryGroupName; - } - }.property('post.primary_group_name'), - - showExpandButton: function() { - if (this.get('controller.firstPostExpanded')) { return false; } - - const post = this.get('post'); - return post.get('post_number') === 1 && post.get('topic.expandable_first_post'); - }.property('post.post_number', 'controller.firstPostExpanded'), - - // If the cooked content changed, add the quote controls - cookedChanged: function() { - Em.run.scheduleOnce('afterRender', this, '_cookedWasChanged'); - }.observes('post.cooked'), - - _cookedWasChanged() { - this.trigger('postViewUpdated', this.$()); - this._insertQuoteControls(); - }, - - mouseUp(e) { - if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) { - this.get('controller').toggledSelectedPost(this.get('post')); - } - }, - - selected: function() { - return this.get('controller').postSelected(this.get('post')); - }.property('controller.selectedPostsCount'), - - canSelectReplies: function() { - if (this.get('post.reply_count') === 0) { return false; } - return !this.get('selected'); - }.property('post.reply_count', 'selected'), - - selectPostText: function() { - return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select'); - }.property('selected', 'controller.selectedPostsCount'), - - repliesShown: Em.computed.gt('post.replies.length', 0), - - _updateQuoteElements($aside, desc) { - let navLink = ""; - const quoteTitle = I18n.t("post.follow_quote"), - postNumber = $aside.data('post'); - - if (postNumber) { - - // If we have a topic reference - let topicId, topic; - if (topicId = $aside.data('topic')) { - topic = this.get('controller.content'); - - // If it's the same topic as ours, build the URL from the topic object - if (topic && topic.get('id') === topicId) { - navLink = ``; - } else { - // Made up slug should be replaced with canonical URL - navLink = ``; - } - - } else if (topic = this.get('controller.content')) { - // assume the same topic - navLink = ``; - } - } - // Only add the expand/contract control if it's not a full post - let expandContract = ""; - if (!$aside.data('full')) { - expandContract = ``; - $('.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.html(result.cooked); - div.highlight(originalText, {caseSensitive: true, element: 'span', className: 'highlighted'}); - $blockQuote.showHtml(div, 'fast', finished); - }); - } else { - // Hide expanded quote - this._updateQuoteElements($aside, 'chevron-down'); - $('blockquote', $aside).showHtml($aside.data('original-contents'), 'fast', finished); - } - return false; - }, - - // Show how many times links have been clicked on - _showLinkCounts() { - const self = this, - link_counts = this.get('post.link_counts'); - - if (!link_counts) { return; } - - link_counts.forEach(function(lc) { - if (!lc.clicks || lc.clicks < 1) { return; } - - self.$(".cooked a[href]").each(function() { - const $link = $(this), - href = $link.attr('href'); - - let valid = !lc.internal && href === lc.url; - - // this might be an attachment - if (lc.internal) { valid = href.indexOf(lc.url) >= 0; } - - if (valid) { - // don't display badge counts on category badge & oneboxes (unless when explicitely stated) - if (isValidLink($link)) { - $link.append("" + number(lc.clicks) + ""); - } - } - }); - }); - }, - - 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; diff --git a/app/assets/javascripts/discourse/views/reply-history.js.es6 b/app/assets/javascripts/discourse/views/reply-history.js.es6 deleted file mode 100644 index e1f2cb45b..000000000 --- a/app/assets/javascripts/discourse/views/reply-history.js.es6 +++ /dev/null @@ -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 -}); diff --git a/app/assets/javascripts/discourse/views/topic-map-container.js.es6 b/app/assets/javascripts/discourse/views/topic-map-container.js.es6 deleted file mode 100644 index 5bd42eb36..000000000 --- a/app/assets/javascripts/discourse/views/topic-map-container.js.es6 +++ /dev/null @@ -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'); - } - } -}); diff --git a/app/assets/javascripts/discourse/widgets/actions-summary.js.es6 b/app/assets/javascripts/discourse/widgets/actions-summary.js.es6 new file mode 100644 index 000000000..a09bc5b36 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/actions-summary.js.es6 @@ -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; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/button.js.es6 b/app/assets/javascripts/discourse/widgets/button.js.es6 new file mode 100644 index 000000000..278d00d24 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/button.js.es6 @@ -0,0 +1,48 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { iconNode } from 'discourse/helpers/fa-icon'; + +export default createWidget('button', { + tagName: 'button', + + buildClasses() { + if (this.attrs.className) { return this.attrs.className; } + }, + + buildAttributes() { + const attrs = this.attrs; + + let title; + if (attrs.title) { + title = I18n.t(attrs.title, attrs.titleOptions); + } + + const attributes = { "aria-label": title, title }; + if (attrs.disabled) { attributes.disabled = "true"; } + + if (attrs.data) { + Object.keys(attrs.data).forEach(k => attributes[`data-${k}`] = attrs.data[k]); + } + + return attributes; + }, + + html(attrs) { + const contents = []; + + const left = !attrs.iconRight; + if (attrs.icon && left) { contents.push(iconNode(attrs.icon)); } + if (attrs.label) { contents.push(I18n.t(attrs.label, attrs.labelOptions)); } + if (attrs.contents) { contents.push(attrs.contents); } + if (attrs.icon && !left) { contents.push(iconNode(attrs.icon)); } + + return contents; + }, + + click() { + const attrs = this.attrs; + if (attrs.disabled) { return; } + + $(`button`).blur(); + return this.sendWidgetAction(attrs.action); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/click-hook.js.es6 b/app/assets/javascripts/discourse/widgets/click-hook.js.es6 new file mode 100644 index 000000000..2b6066eb5 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/click-hook.js.es6 @@ -0,0 +1,61 @@ +const CLICK_ATTRIBUTE_NAME = '_discourse_click_widget'; +const CLICK_OUTSIDE_ATTRIBUTE_NAME = '_discourse_click_outside_widget'; + +export class WidgetClickHook { + constructor(widget) { + this.widget = widget; + } + + hook(node) { + node[CLICK_ATTRIBUTE_NAME] = this.widget; + } + + unhook(node) { + node[CLICK_ATTRIBUTE_NAME] = null; + } +}; + +export class WidgetClickOutsideHook { + constructor(widget) { + this.widget = widget; + } + + hook(node) { + node.setAttribute('data-click-outside', true); + node[CLICK_OUTSIDE_ATTRIBUTE_NAME] = this.widget; + } + + unhook(node) { + node.removeAttribute('data-click-outside'); + node[CLICK_OUTSIDE_ATTRIBUTE_NAME] = null; + } +}; + +let _watchingDocument = false; +WidgetClickHook.setupDocumentCallback = function() { + if (_watchingDocument) { return; } + + $(document).on('click.discourse-widget', e => { + let node = e.target; + while (node) { + const widget = node[CLICK_ATTRIBUTE_NAME]; + if (widget) { + return widget.click(e); + } + node = node.parentNode; + } + + node = e.target; + const $outside = $('[data-click-outside]'); + $outside.each((i, outNode) => { + if (outNode.contains(node)) { return; } + const widget = outNode[CLICK_OUTSIDE_ATTRIBUTE_NAME]; + if (widget) { + return widget.clickOutside(e); + } + }); + }); + + + _watchingDocument = true; +}; diff --git a/app/assets/javascripts/discourse/widgets/embedded-post.js.es6 b/app/assets/javascripts/discourse/widgets/embedded-post.js.es6 new file mode 100644 index 000000000..62da30e9b --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/embedded-post.js.es6 @@ -0,0 +1,42 @@ +import RawHtml from 'discourse/widgets/raw-html'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { iconNode } from 'discourse/helpers/fa-icon'; +import DiscourseURL from 'discourse/lib/url'; + +createWidget('post-link-arrow', { + html(attrs) { + if (attrs.above) { + return h('a.post-info.arrow', { + attributes: { title: I18n.t('topic.jump_reply_up') } + }, iconNode('arrow-up')); + } else { + return h('a.post-info.arrow', { + attributes: { title: I18n.t('topic.jump_reply_down') } + }, iconNode('arrow-down')); + } + }, + + click() { + DiscourseURL.jumpToPost(this.attrs.post_number); + } +}); + +export default createWidget('embedded-post', { + buildKey: attrs => `embedded-post-${attrs.id}`, + + html(attrs, state) { + return [ + h('div.row', [ + this.attach('post-avatar', attrs), + h('div.topic-body', [ + h('div.topic-meta-data', [ + this.attach('poster-name', attrs), + this.attach('post-link-arrow', { above: state.above, post_number: attrs.post_number }) + ]), + new RawHtml({html: `
${attrs.cooked}
`}) + ]) + ]) + ]; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/event-handler.js.es6 b/app/assets/javascripts/discourse/widgets/event-handler.js.es6 new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 new file mode 100644 index 000000000..16590124f --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 @@ -0,0 +1,66 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; + +createWidget('post-admin-menu-button', { + tagName: 'li.btn', + buildClasses(attrs) { + return attrs.className; + }, + html(attrs) { + return [iconNode(attrs.icon), I18n.t(attrs.label)]; + }, + click() { + this.sendWidgetAction('closeAdminMenu'); + return this.sendWidgetAction(this.attrs.action); + } +}); + +export default createWidget('post-admin-menu', { + tagName: 'div.post-admin-menu.popup-menu', + + html(attrs) { + const contents = []; + contents.push(h('h3', I18n.t('admin_title'))); + + if (this.currentUser.staff) { + const buttonAtts = { action: 'togglePostType', icon: 'shield', className: 'toggle-post-type' }; + + if (attrs.isModeratorAction) { + buttonAtts.label = 'post.controls.revert_to_regular'; + } else { + buttonAtts.label = 'post.controls.convert_to_moderator'; + } + contents.push(this.attach('post-admin-menu-button', buttonAtts)); + } + + contents.push(this.attach('post-admin-menu-button', { + icon: 'cog', label: 'post.controls.rebake', action: 'rebakePost', className: 'rebuild-html' + })); + + if (attrs.hidden) { + contents.push(this.attach('post-admin-menu-button', { + icon: 'eye', + label: 'post.controls.unhide', + action: 'unhidePost', + className: 'unhide-post' + })); + } + + if (this.currentUser.admin) { + contents.push(this.attach('post-admin-menu-button', { + icon: 'user', + label: 'post.controls.change_owner', + action: 'changePostOwner', + className: 'change-owner' + })); + } + + return contents; + }, + + clickOutside() { + this.sendWidgetAction('closeAdminMenu'); + } +}); + diff --git a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 new file mode 100644 index 000000000..b4d0a7bde --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 @@ -0,0 +1,196 @@ +import { isValidLink } from 'discourse/lib/click-track'; +import { number } from 'discourse/lib/formatter'; + +export default class PostCooked { + + constructor(attrs) { + this.attrs = attrs; + this.expanding = false; + this._highlighted = false; + } + + update(prev) { + if (prev.attrs.cooked !== this.attrs.cooked) { + return this.init(); + } + } + + init() { + const $html = $(`
${this.attrs.cooked}
`); + this._insertQuoteControls($html); + this._showLinkCounts($html); + this._fixImageSizes($html); + this._applySearchHighlight($html); + return $html[0]; + } + + _applySearchHighlight($html) { + const highlight = this.attrs.highlightTerm; + + if (highlight && highlight.length > 2) { + if (this._highlighted) { + $html.unhighlight(); + } + $html.highlight(highlight.split(/\s+/)); + this._highlighted = true; + + } else if (this._highlighted) { + $html.unhighlight(); + this._highlighted = false; + } + } + + _fixImageSizes($html) { + const maxImageWidth = Discourse.SiteSettings.max_image_width; + const maxImageHeight = Discourse.SiteSettings.max_image_height; + + let maxWindowWidth; + $html.find('img:not(.avatar)').each((idx,img) => { + // deferring work only for posts with images + // we got to use screen here, cause nothing is rendered yet. + // long term we may want to allow for weird margins that are enforced, instead of hardcoding at 70/20 + maxWindowWidth = maxWindowWidth || $(window).width() - (Discourse.Mobile.mobileView ? 20 : 70); + if (maxImageWidth < maxWindowWidth) { + maxWindowWidth = maxImageWidth; + } + + const aspect = img.height / img.width; + if (img.width > maxWindowWidth) { + img.width = maxWindowWidth; + img.height = parseInt(maxWindowWidth * aspect,10); + } + + // very unlikely but lets fix this too + if (img.height > maxImageHeight) { + img.height = maxImageHeight; + img.width = parseInt(maxWindowWidth / aspect,10); + } + }); + } + + _showLinkCounts($html) { + const linkCounts = this.attrs.linkCounts; + if (!linkCounts) { return; } + + linkCounts.forEach(lc => { + if (!lc.clicks || lc.clicks < 1) { return; } + + $html.find('a[href]').each((i, e) => { + const $link = $(e); + const href = $link.attr('href'); + + let valid = !lc.internal && href === lc.url; + + // this might be an attachment + if (lc.internal) { valid = href.indexOf(lc.url) >= 0; } + + // don't display badge counts on category badge & oneboxes (unless when explicitely stated) + if (valid && isValidLink($link)) { + const title = I18n.t("topic_map.clicks", {count: lc.clicks}); + $link.append(`${number(lc.clicks)}`); + } + }); + }); + } + + _toggleQuote($aside) { + if (this.expanding) { return; } + + this.expanding = true; + + $aside.data('expanded', !$aside.data('expanded')); + + const finished = () => this.expanding = false; + + if ($aside.data('expanded')) { + this._updateQuoteElements($aside, 'chevron-up'); + // Show expanded quote + const $blockQuote = $('blockquote', $aside); + $aside.data('original-contents', $blockQuote.html()); + + const originalText = $blockQuote.text().trim(); + $blockQuote.html(I18n.t("loading")); + let topicId = this.attrs.topicId; + if ($aside.data('topic')) { + topicId = $aside.data('topic'); + } + + const postId = parseInt($aside.data('post'), 10); + topicId = parseInt(topicId, 10); + + Discourse.ajax(`/posts/by_number/${topicId}/${postId}`).then(result => { + const div = $("
"); + div.html(result.cooked); + div.highlight(originalText, {caseSensitive: true, element: 'span', className: 'highlighted'}); + $blockQuote.showHtml(div, 'fast', finished); + }); + } else { + // Hide expanded quote + this._updateQuoteElements($aside, 'chevron-down'); + $('blockquote', $aside).showHtml($aside.data('original-contents'), 'fast', finished); + } + return false; + } + + _urlForPostNumber(postNumber) { + return (postNumber > 0) ? `${this.topicUrl}/${postNumber}` : this.topicUrl; + } + + _updateQuoteElements($aside, desc) { + let navLink = ""; + const quoteTitle = I18n.t("post.follow_quote"); + const postNumber = $aside.data('post'); + + if (postNumber) { + + // If we have a topic reference + const asideTopicId = parseInt($aside.data('topic')); + + if (asideTopicId) { + // If it's the same topic as ours, build the URL from the topic object + if (this.topicId === asideTopicId) { + navLink = ``; + } else { + // Made up slug should be replaced with canonical URL + const asideLink = Discourse.getURL("/t/via-quote/") + asideTopicId + "/" + postNumber; + navLink = ``; + } + + } else { + // assume the same topic + navLink = ``; + } + } + // Only add the expand/contract control if it's not a full post + let expandContract = ""; + if (!$aside.data('full')) { + expandContract = ``; + $('.title', $aside).css('cursor', 'pointer'); + } + $('.quote-controls', $aside).html(expandContract + navLink); + } + + _insertQuoteControls($html) { + const $quotes = $html.find('aside.quote'); + if ($quotes.length === 0) { return; } + + $quotes.each((i, e) => { + const $aside = $(e); + if ($aside.data('post')) { + this._updateQuoteElements($aside, 'chevron-down'); + const $title = $('.title', $aside); + + // Unless it's a full quote, allow click to expand + if (!($aside.data('full') || $title.data('has-quote-controls'))) { + $title.on('click', e2 => { + if ($(e2.target).is('a')) return true; + this._toggleQuote($aside); + }); + $title.data('has-quote-controls', true); + } + } + }); + } +} + +PostCooked.prototype.type = 'Widget'; diff --git a/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 b/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 new file mode 100644 index 000000000..5ae62a3f1 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 @@ -0,0 +1,41 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { iconNode } from 'discourse/helpers/fa-icon'; +import { longDate } from 'discourse/lib/formatter'; +import { h } from 'virtual-dom'; + +const FIFTY_HOURS = 60 * 50 * 1000; + +export default createWidget('post-edits-indicator', { + tagName: 'div.post-info.edits', + + historyHeat(updatedAt) { + if (!updatedAt) { return; } + + // Show heat on age + const rightNow = new Date().getTime(); + const updatedAtTime = updatedAt.getTime(); + + const siteSettings = this.siteSettings; + if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_low)) return 'heatmap-high'; + if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_medium)) return 'heatmap-med'; + if (updatedAtTime > (rightNow - FIFTY_HOURS * siteSettings.history_hours_high)) return 'heatmap-low'; + }, + + html(attrs) { + const contents = [attrs.version - 1, ' ', iconNode('pencil')]; + const updatedAt = new Date(attrs.updated_at); + + const title = `${I18n.t('post.last_edited_on')} ${longDate(updatedAt)}`; + return h('a', { + className: this.historyHeat(updatedAt), + attributes: { title } + }, contents); + }, + + click() { + if (this.attrs.canViewEditHistory) { + this.sendWidgetAction('showHistory'); + } + } +}); + diff --git a/app/assets/javascripts/discourse/widgets/post-gap.js.es6 b/app/assets/javascripts/discourse/widgets/post-gap.js.es6 new file mode 100644 index 000000000..d14d1b811 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-gap.js.es6 @@ -0,0 +1,26 @@ +import { createWidget } from 'discourse/widgets/widget'; + +export default createWidget('post-gap', { + tagName: 'div.gap.jagged-border', + buildKey: (attrs) => `post-gap-${attrs.pos}-${attrs.postId}`, + + defaultState() { + return { loading: false }; + }, + + html(attrs, state) { + return state.loading ? I18n.t('loading') : I18n.t('post.gap', {count: attrs.gap.length}); + }, + + click() { + const { attrs, state } = this; + + if (state.loading) { return; } + state.loading = true; + + this.scheduleRerender(); + + const args = { gap: attrs.gap, post: this.model }; + return this.sendWidgetAction(attrs.pos === 'before' ? 'fillGapBefore' : 'fillGapAfter', args); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/post-gutter.js.es6 b/app/assets/javascripts/discourse/widgets/post-gutter.js.es6 new file mode 100644 index 000000000..514ee4a92 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-gutter.js.es6 @@ -0,0 +1,68 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; + +const MAX_GUTTER_LINKS = 5; + +export default createWidget('post-gutter', { + tagName: 'div.gutter', + + defaultState() { + return { collapsed: true }; + }, + + html(attrs, state) { + const links = this.attrs.links || []; + + const result = []; + let toShow = links.length; + if (state.collapsed && toShow > MAX_GUTTER_LINKS) { toShow = MAX_GUTTER_LINKS; } + + const seenTitles = {}; + + + let i = 0; + while (i < links.length && result.length < toShow) { + const l = links[i++]; + + let title = l.title; + if (title && !seenTitles[title]) { + seenTitles[title] = true; + const linkBody = [Discourse.Emoji.unescape(title)]; + if (l.clicks) { + linkBody.push(h('span.badge.badge-notification.clicks', l.clicks.toString())); + } + + const link = h('a.track-link', { attributes: { href: l.url } }, linkBody); + result.push(h('li', link)); + } + } + + if (state.collapsed) { + const remaining = links.length - MAX_GUTTER_LINKS; + if (remaining > 0) { + result.push(h('li', h('a.toggle-more', I18n.t('post.more_links', {count: remaining})))); + } + } + + if (attrs.canReplyAsNewTopic) { + result.push(h('a.reply-new', [iconNode('plus'), I18n.t('post.reply_as_new_topic')])); + } + + return h('ul.post-links', result); + }, + + click(e) { + const $target = $(e.target); + if ($target.hasClass('toggle-more')) { + this.sendWidgetAction('showAll'); + } else if ($target.closest('.reply-new').length) { + this.sendWidgetAction('newTopicAction'); + } + return true; + }, + + showAll() { + this.state.collapsed = false; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 new file mode 100644 index 000000000..f4c4d9150 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -0,0 +1,306 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { avatarAtts } from 'discourse/widgets/actions-summary'; +import { h } from 'virtual-dom'; + +const LIKE_ACTION = 2; + +function animateHeart($elem, start, end, complete) { + if (Ember.testing) { return Ember.run(this, complete); } + + $elem.stop() + .css('textIndent', start) + .animate({ textIndent: end }, { + complete, + step(now) { + $(this).css('transform','scale('+now+')'); + }, + duration: 150 + }, 'linear'); +} + +const _builders = {}; + +function registerButton(name, builder) { + _builders[name] = builder; +} + +registerButton('like', attrs => { + if (!attrs.showLike) { return; } + const className = attrs.liked ? 'has-like fade-out' : 'like'; + + if (attrs.canToggleLike) { + const descKey = attrs.liked ? 'post.controls.undo_like' : 'post.controls.like'; + return { action: 'like', title: descKey, icon: 'heart', className }; + } else if (attrs.liked) { + return { action: 'like', title: 'post.controls.has_liked', icon: 'heart', className, disabled: true }; + } +}); + +registerButton('like-count', attrs => { + const count = attrs.likeCount; + + if (count > 0) { + const title = attrs.liked + ? count === 1 ? 'post.has_likes_title_only_you' : 'post.has_likes_title_you' + : 'post.has_likes_title'; + + return { action: 'toggleWhoLiked', + title, + className: 'like-count highlight-action', + contents: I18n.t("post.has_likes", { count }), + titleOptions: {count: attrs.liked ? (count-1) : count } + }; + } +}); + +registerButton('flag', attrs => { + if (attrs.canFlag) { + return { action: 'showFlags', + title: 'post.controls.flag', + icon: 'flag', + className: 'create-flag' }; + } +}); + +registerButton('edit', attrs => { + if (attrs.canEdit) { + return { + action: 'editPost', + className: 'edit', + title: 'post.controls.edit', + icon: 'pencil', + alwaysShowYours: true, + alwaysShowWiki: true + }; + } +}); + +registerButton('replies', (attrs, state, siteSettings) => { + const replyCount = attrs.replyCount; + + if (!replyCount) { return; } + + // Omit replies if the setting `suppress_reply_directly_below` is enabled + if (replyCount === 1 && + attrs.replyDirectlyBelow && + siteSettings.suppress_reply_directly_below) { + return; + } + + return { + action: 'toggleRepliesBelow', + className: 'show-replies', + icon: state.repliesShown ? 'chevron-up' : 'chevron-down', + titleOptions: { count: replyCount }, + title: 'post.has_replies', + labelOptions: { count: replyCount }, + label: 'post.has_replies', + iconRight: true + }; +}); + + +registerButton('share', attrs => { + return { + action: 'share', + title: 'post.controls.share', + icon: 'link', + data: { + 'share-url': attrs.shareUrl, + 'post-number': attrs.post_number + } + }; +}); + +registerButton('reply', attrs => { + const args = { + action: 'replyToPost', + title: 'post.controls.reply', + icon: 'reply', + className: 'reply create fade-out' + }; + + if (!attrs.canCreatePost) { return; } + + if (!Discourse.Mobile.mobileView) { + args.label = 'topic.reply.title'; + } + + return args; +}); + +registerButton('bookmark', attrs => { + if (!attrs.canBookmark) { return; } + + let iconClass = 'read-icon'; + let buttonClass = 'bookmark'; + let tooltip = 'bookmarks.not_bookmarked'; + + if (attrs.bookmarked) { + iconClass += ' bookmarked'; + buttonClass += ' bookmarked'; + tooltip = 'bookmarks.created'; + } + + return { action: 'toggleBookmark', + title: tooltip, + className: buttonClass, + contents: h('div', { className: iconClass }) }; +}); + +registerButton('admin', attrs => { + if (!attrs.canManage) { return; } + return { action: 'openAdminMenu', + title: 'post.controls.admin', + className: 'show-post-admin-menu', + icon: 'wrench' }; +}); + +registerButton('delete', attrs => { + if (attrs.canRecoverTopic) { + return { action: 'recoverPost', title: 'topic.actions.recover', icon: 'undo', className: 'recover' }; + } else if (attrs.canDeleteTopic) { + return { action: 'deletePost', title: 'topic.actions.delete', icon: 'trash-o', className: 'delete' }; + } else if (attrs.canRecover) { + return { action: 'recoverPost', title: 'post.controls.undelete', icon: 'undo', className: 'recover' }; + } else if (attrs.canDelete) { + return { action: 'deletePost', title: 'post.controls.delete', icon: 'trash-o', className: 'delete' }; + } +}); + +registerButton('wiki', attrs => { + if (!attrs.canWiki) { return; } + + if (attrs.wiki) { + return { action: 'toggleWiki', + title: 'post.controls.unwiki', + icon: 'pencil-square-o', + className: 'wiki wikied' }; + } else { + return { action: 'toggleWiki', + title: 'post.controls.wiki', + icon: 'pencil-square-o', + className: 'wiki' }; + } +}); + +export default createWidget('post-menu', { + tagName: 'section.post-menu-area.clearfix', + + defaultState() { + return { collapsed: true, likedUsers: [], adminVisible: false }; + }, + + buildKey: attrs => `post-menu-${attrs.id}`, + + attachButton(name, attrs) { + const builder = _builders[name]; + if (builder) { + const buttonAtts = builder(attrs, this.state, this.siteSettings); + if (buttonAtts) { + return this.attach('button', buttonAtts); + } + } + }, + + html(attrs, state) { + const { siteSettings } = this; + + const hiddenSetting = (siteSettings.post_menu_hidden_items || ''); + const hiddenButtons = hiddenSetting.split('|').filter(s => { + return !attrs.bookmarked || s !== 'bookmark'; + }); + + const allButtons = []; + let visibleButtons = []; + siteSettings.post_menu.split('|').forEach(i => { + const button = this.attachButton(i, attrs); + if (button) { + allButtons.push(button); + if ((attrs.yours && button.attrs.alwaysShowYours) || + (attrs.wiki && button.attrs.alwaysShowWiki) || + (hiddenButtons.indexOf(i) === -1)) { + visibleButtons.push(button); + } + } + }); + + // Only show ellipsis if there is more than one button hidden + // if there are no more buttons, we are not collapsed + if (!state.collapsed || (allButtons.length <= visibleButtons.length + 1)) { + visibleButtons = allButtons; + if (state.collapsed) { state.collapsed = false; } + } else { + const showMore = this.attach('button', { + action: 'showMoreActions', + title: 'show_more', + className: 'show-more-actions', + icon: 'ellipsis-h' }); + visibleButtons.splice(visibleButtons.length - 1, 0, showMore); + } + + const postControls = []; + + const repliesButton = this.attachButton('replies', attrs); + if (repliesButton) { + postControls.push(repliesButton); + } + + postControls.push(h('div.actions', visibleButtons)); + if (state.adminVisible) { + postControls.push(this.attach('post-admin-menu', attrs)); + } + + const contents = [ h('nav.post-controls.clearfix', postControls) ]; + if (state.likedUsers.length) { + contents.push(this.attach('small-user-list', { + users: state.likedUsers, + addSelf: attrs.liked, + listClassName: 'who-liked', + description: 'post.actions.people.like' + })); + } + + return contents; + }, + + openAdminMenu() { + this.state.adminVisible = true; + }, + + closeAdminMenu() { + this.state.adminVisible = false; + }, + + showMoreActions() { + this.state.collapsed = false; + }, + + like() { + const attrs = this.attrs; + if (attrs.liked) { + return this.sendWidgetAction('toggleLike'); + } + + const $heart = $(`[data-post-id=${attrs.id}] .fa-heart`); + const scale = [1.0, 1.5]; + return new Ember.RSVP.Promise(resolve => { + animateHeart($heart, scale[0], scale[1], () => { + animateHeart($heart, scale[1], scale[0], () => { + this.sendWidgetAction('toggleLike').then(() => resolve()); + }); + }); + }); + }, + + toggleWhoLiked() { + const { attrs, state } = this; + if (state.likedUsers.length) { + state.likedUsers = []; + } else { + return this.store.find('post-action-user', { id: attrs.id, post_action_type_id: LIKE_ACTION }).then(users => { + state.likedUsers = users.map(avatarAtts); + }); + } + }, +}); diff --git a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 new file mode 100644 index 000000000..e4590e1d4 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 @@ -0,0 +1,59 @@ +import { createWidget } from 'discourse/widgets/widget'; +import RawHtml from 'discourse/widgets/raw-html'; +import { iconNode } from 'discourse/helpers/fa-icon'; +import { h } from 'virtual-dom'; +import { actionDescriptionHtml } from 'discourse/components/small-action'; + +const icons = { + 'closed.enabled': 'lock', + 'closed.disabled': 'unlock-alt', + 'autoclosed.enabled': 'lock', + 'autoclosed.disabled': 'unlock-alt', + 'archived.enabled': 'folder', + 'archived.disabled': 'folder-open', + 'pinned.enabled': 'thumb-tack', + 'pinned.disabled': 'thumb-tack unpinned', + 'pinned_globally.enabled': 'thumb-tack', + 'pinned_globally.disabled': 'thumb-tack unpinned', + 'visible.enabled': 'eye', + 'visible.disabled': 'eye-slash', + 'split_topic': 'sign-out', + 'invited_user': 'plus-circle', + 'removed_user': 'minus-circle' +}; + +export default createWidget('post-small-action', { + tagName: 'div.small-action.clearfix', + + html(attrs) { + const contents = []; + + if (attrs.canDelete) { + contents.push(this.attach('button', { + icon: 'times', + action: 'deletePost', + title: 'post.controls.delete' + })); + } + + if (attrs.canEdit) { + contents.push(this.attach('button', { + icon: 'pencil', + action: 'editPost', + title: 'post.controls.edit' + })); + } + + const description = actionDescriptionHtml(attrs.actionCode, attrs.created_at, attrs.actionCodeWho); + contents.push(new RawHtml({ html: `

${description}

` })); + + if (attrs.cooked) { + contents.push(new RawHtml({ html: `
${attrs.cooked}
` })); + } + + return [ + h('div.topic-avatar', iconNode(icons[attrs.actionCode] || 'exclamation')), + h('div.small-action-desc', contents) + ]; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/post-stream.js.es6 b/app/assets/javascripts/discourse/widgets/post-stream.js.es6 new file mode 100644 index 000000000..723ef1ab4 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-stream.js.es6 @@ -0,0 +1,75 @@ +import { createWidget } from 'discourse/widgets/widget'; +import transformPost from 'discourse/lib/transform-post'; + +const DAY = 1000 * 60 * 60 * 24; + +export default createWidget('post-stream', { + tagName: 'div.post-stream', + + + html(attrs) { + const posts = attrs.posts || []; + const postArray = posts.toArray(); + + const result = []; + + const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {}; + const after = attrs.gaps && attrs.gaps.before ? attrs.gaps.after : {}; + + let prevPost; + let prevDate; + + for (let i=0; i this.siteSettings.show_time_gap_days) { + result.push(this.attach('time-gap', { daysSince })); + } + } + prevDate = curTime; + + // actual post contents + if (transformed.isSmallAction) { + result.push(this.attach('post-small-action', transformed, { model: post })); + } else { + result.push(this.attach('post', transformed, { model: post })); + } + + // Post gap - after + const afterGap = after[post.id]; + if (afterGap) { + result.push(this.attach('post-gap', { pos: 'after', postId: post.id, gap: afterGap }, { model: post })); + } + + prevPost = post; + } + return result; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 new file mode 100644 index 000000000..06916e942 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -0,0 +1,395 @@ +import PostCooked from 'discourse/widgets/post-cooked'; +import { createWidget } from 'discourse/widgets/widget'; +import { iconNode } from 'discourse/helpers/fa-icon'; +import { transformBasicPost } from 'discourse/lib/transform-post'; +import { h } from 'virtual-dom'; +import DiscourseURL from 'discourse/lib/url'; +import { dateNode } from 'discourse/helpers/node'; + +export function avatarImg(wanted, attrs) { + const size = Discourse.Utilities.translateSize(wanted); + const url = Discourse.Utilities.avatarUrl(attrs.template, size); + + // We won't render an invalid url + if (!url || url.length === 0) { return; } + const title = attrs.username; + + const properties = { + attributes: { alt: '', width: size, height: size, src: Discourse.getURLWithCDN(url), title }, + className: 'avatar' + }; + + return h('img', properties); +} + +export function avatarFor(wanted, attrs) { + return h('a', { + className: `trigger-user-card ${attrs.className || ''}`, + attributes: { href: attrs.url, 'data-user-card': attrs.username } + }, avatarImg(wanted, attrs)); +} + +createWidget('select-post', { + tagName: 'div.select-posts', + + html(attrs) { + const buttons = []; + + if (attrs.replyCount > 0 && !attrs.selected) { + buttons.push(this.attach('button', { label: 'topic.multi_select.select_replies', action: 'selectReplies' })); + } + + const selectPostKey = attrs.selected ? 'topic.multi_select.selected' : 'topic.multi_select.select'; + buttons.push(this.attach('button', { className: 'select-post', + label: selectPostKey, + labelOptions: { count: attrs.selectedPostsCount }, + action: 'selectPost' })); + return buttons; + } +}); + +createWidget('reply-to-tab', { + tagName: 'a.reply-to-tab', + + defaultState() { + return { loading: false }; + }, + + html(attrs, state) { + if (state.loading) { return I18n.t('loading'); } + + return [iconNode('mail-forward'), + ' ', + avatarFor.call(this, 'small', { + template: attrs.replyToAvatarTemplate, + username: attrs.replyToUsername + }), + ' ', + h('span', attrs.replyToUsername)]; + }, + + click() { + this.state.loading = true; + this.sendWidgetAction('toggleReplyAbove').then(() => this.state.loading = false); + } +}); + +createWidget('post-avatar', { + tagName: 'div.topic-avatar', + + html(attrs) { + let body; + if (!attrs.user_id) { + body = h('i', { className: 'fa fa-trash-o deleted-user-avatar' }); + } else { + body = avatarFor.call(this, 'large', { + template: attrs.avatar_template, + username: attrs.username, + url: attrs.usernameUrl, + className: 'main-avatar' + }); + } + + return [body, h('div.poster-avatar-extra')]; + } +}); + + +createWidget('wiki-edit-button', { + tagName: 'div.post-info.wiki', + title: 'post.wiki.about', + + html() { + return iconNode('pencil-square-o'); + }, + + click() { + this.sendWidgetAction('editPost'); + } +}); + +createWidget('post-email-indicator', { + tagName: 'div.post-info.via-email', + title: 'post.via_email', + + buildClasses(attrs) { + return attrs.canViewRawEmail ? 'raw-email' : null; + }, + + html() { + return iconNode('envelope-o'); + }, + + click() { + if (this.attrs.canViewRawEmail) { + this.sendWidgetAction('showRawEmail'); + } + } +}); + +function showReplyTab(attrs, siteSettings) { + return attrs.replyToUsername && + (!attrs.replyDirectlyAbove || !siteSettings.suppress_reply_directly_above); +} + +createWidget('post-meta-data', { + tagName: 'div.topic-meta-data', + html(attrs) { + const result = [this.attach('poster-name', attrs)]; + + if (attrs.isWhisper) { + result.push(h('div.post-info.whisper', { + attributes: { title: I18n.t('post.whisper') }, + }, iconNode('eye-slash'))); + } + + const createdAt = new Date(attrs.created_at); + if (createdAt) { + result.push(h('div.post-info', + h('a.post-date', { + attributes: { + href: attrs.shareUrl, + 'data-share-url': attrs.shareUrl, + 'data-post-number': attrs.post_number + } + }, dateNode(createdAt)) + )); + } + + if (attrs.via_email) { + result.push(this.attach('post-email-indicator', attrs)); + } + + if (attrs.version > 1) { + result.push(this.attach('post-edits-indicator', attrs)); + } + + if (attrs.wiki) { + result.push(this.attach('wiki-edit-button', attrs)); + } + + if (attrs.multiSelect) { + result.push(this.attach('select-post', attrs)); + } + + if (showReplyTab(attrs, this.siteSettings)) { + result.push(this.attach('reply-to-tab', attrs)); + } + + result.push(h('div.read-state', { + className: attrs.read ? 'read' : null, + attributes: { + title: I18n.t('post.unread') + } + }, iconNode('circle'))); + + return result; + } +}); + +createWidget('expand-hidden', { + tagName: 'a.expand-hidden', + + html() { + return I18n.t('post.show_hidden'); + }, + + click() { + this.sendWidgetAction('expandHidden'); + } +}); + +createWidget('expand-post-button', { + tagName: 'button.btn.expand-post', + buildKey: attrs => `expand-post-button-${attrs.id}`, + + defaultState() { + return { loadingExpanded: false }; + }, + + html(attrs, state) { + if (state.loadingExpanded) { + return I18n.t('loading'); + } else { + return [I18n.t('post.show_full'), "..."]; + } + }, + + click() { + this.state.loadingExpanded = true; + this.sendWidgetAction('expandFirstPost'); + } +}); + +createWidget('post-contents', { + buildKey: attrs => `post-contents-${attrs.id}`, + + buildClasses(attrs) { + const classes = []; + if (!this.state.repliesShown) { + classes.push('contents'); + } + if (showReplyTab(attrs, this.siteSettings)) { + classes.push('avoid-tab'); + } + return classes; + }, + + html(attrs) { + return new PostCooked(attrs); + } +}); + +createWidget('post-body', { + tagName: 'div.topic-body', + buildKey: attrs => `topic-body-${attrs.id}`, + + defaultState() { + return { expandedFirstPost: false, repliesBelow: [] }; + }, + + html(attrs, state) { + const extraState = { state: { repliesShown: !!state.repliesBelow.length } }; + const regular = [this.attach('post-contents', attrs, extraState)]; + + if (attrs.cooked_hidden) { + regular.push(this.attach('expand-hidden', attrs)); + } + + if (!state.expandedFirstPost && attrs.expandablePost) { + regular.push(this.attach('expand-post-button', attrs)); + } + + regular.push(this.attach('post-menu', attrs, extraState)); + + const repliesBelow = state.repliesBelow; + if (repliesBelow.length) { + regular.push(h('section.embedded-posts.bottom', + repliesBelow.map(p => this.attach('embedded-post', p)))); + } + + const result = [this.attach('post-meta-data', attrs), h('div.regular', regular)]; + result.push(this.attach('actions-summary', attrs)); + if (attrs.showTopicMap) { + result.push(this.attach('topic-map', attrs)); + } + + return result; + }, + + toggleRepliesBelow() { + if (this.state.repliesBelow.length) { + this.state.repliesBelow = []; + return; + } + + return this.store.find('post-reply', { postId: this.attrs.id }).then(posts => { + this.state.repliesBelow = posts.map(transformBasicPost); + }); + }, + + expandFirstPost() { + const post = this.findAncestorModel(); + return post.expand().then(() => this.state.expandedFirstPost = true); + } +}); + +createWidget('post-article', { + tagName: 'article.boxed', + buildKey: attrs => `post-article-${attrs.id}`, + + defaultState() { + return { repliesAbove: [] }; + }, + + buildId(attrs) { + return `post_${attrs.post_number}`; + }, + + buildClasses(attrs) { + if (attrs.via_email) { return 'via-email'; } + }, + + buildAttributes(attrs) { + return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id }; + }, + + html(attrs, state) { + const rows = []; + if (state.repliesAbove.length) { + const replies = state.repliesAbove.map(p => this.attach('embedded-post', p, { state: { above: true } })); + rows.push(h('div.row', h('section.embedded-posts.top.topic-body.offset2', replies))); + } + + rows.push(h('div.row', [this.attach('post-avatar', attrs), + this.attach('post-body', attrs), + this.attach('post-gutter', attrs)])); + return rows; + }, + + toggleReplyAbove() { + const replyPostNumber = this.attrs.reply_to_post_number; + + // jump directly on mobile + if (Discourse.Mobile.mobileView) { + DiscourseURL.jumpToPost(replyPostNumber); + return Ember.RSVP.Promise.resolve(); + } + + if (this.state.repliesAbove.length) { + this.state.repliesAbove = []; + return Ember.RSVP.Promise.resolve(); + } else { + return this.store.find('post-reply-history', { postId: this.attrs.id }).then(posts => { + this.state.repliesAbove = posts.map(transformBasicPost); + }); + } + }, + +}); + +export default createWidget('post', { + buildKey: attrs => `post-${attrs.id}`, + shadowTree: true, + + buildClasses(attrs) { + const classNames = ['topic-post', 'clearfix']; + + if (attrs.selected) { classNames.push('selected'); } + if (attrs.topicOwner) { classNames.push('topic-owner'); } + if (attrs.hidden) { classNames.push('post-hidden'); } + if (attrs.deleted) { classNames.push('deleted'); } + if (attrs.primary_group_name) { classNames.push(`group-${attrs.primary_group_name}`); } + if (attrs.wiki) { classNames.push(`wiki`); } + if (attrs.isWhisper) { classNames.push('whisper'); } + if (attrs.isModeratorAction || (attrs.isWarning && attrs.firstPost)) { + classNames.push('moderator'); + } else { + classNames.push('regular'); + } + return classNames; + }, + + html(attrs) { + return this.attach('post-article', attrs); + }, + + toggleLike() { + const post = this.model; + const likeAction = post.get('likeAction'); + + if (likeAction && likeAction.get('canToggle')) { + return likeAction.togglePromise(post); + } + }, + + undoPostAction(typeId) { + const post = this.model; + return post.get('actions_summary').findProperty('id', typeId).undo(post); + }, + + deferPostActionFlags(typeId) { + const post = this.model; + return post.get('actions_summary').findProperty('id', typeId).deferFlags(post); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/poster-name.js.es6 b/app/assets/javascripts/discourse/widgets/poster-name.js.es6 new file mode 100644 index 000000000..3c91a68d5 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/poster-name.js.es6 @@ -0,0 +1,61 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; + +function sanitizeName(name){ + return name.toLowerCase().replace(/[\s_-]/g,''); +} + +export default createWidget('poster-name', { + tagName: 'div.names.trigger-user-card', + + // TODO: Allow extensibility + posterGlyph(attrs) { + if (attrs.moderator) { + return iconNode('shield', { title: I18n.t('user.moderator_tooltip') }); + } + }, + + userLink(attrs, text) { + return h('a', { attributes: { + href: attrs.usernameUrl, + 'data-auto-route': true, + 'data-user-card': attrs.username + } }, text); + }, + + html(attrs) { + const username = attrs.username; + const classNames = ['username']; + + if (attrs.staff) { classNames.push('staff'); } + if (attrs.admin) { classNames.push('admin'); } + if (attrs.moderator) { classNames.push('moderator'); } + if (attrs.new_user) { classNames.push('new-user'); } + + const primaryGroupName = attrs.primary_group_name; + if (primaryGroupName && primaryGroupName.length) { + classNames.push(primaryGroupName); + } + const nameContents = [ this.userLink(attrs, attrs.username) ]; + const glyph = this.posterGlyph(attrs); + if (glyph) { nameContents.push(glyph); } + + const contents = [h('span', { className: classNames.join(' ') }, nameContents)]; + const name = attrs.name; + if (name && this.siteSettings.display_name_on_posts && sanitizeName(name) !== sanitizeName(username)) { + contents.push(h('span.full-name', this.userLink(attrs, name))); + } + const title = attrs.user_title; + if (title && title.length) { + let titleContents = title; + if (primaryGroupName) { + const href = Discourse.getURL(`/groups/${primaryGroupName}`); + titleContents = h('a.user-group', { attributes: { href } }, title); + } + contents.push(h('span.user-title', titleContents)); + } + + return contents; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 b/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 new file mode 100644 index 000000000..9fe250d5c --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 @@ -0,0 +1,76 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { avatarFor } from 'discourse/widgets/post'; + +createWidget('pm-map-user-group', { + tagName: 'div.user.group', + + html(attrs) { + const link = h('a', { attributes: { href: Discourse.getURL(`/groups/${attrs.name}`) } }, attrs.name); + return [iconNode('users'), ' ', link]; + } +}); + +createWidget('pm-remove-link', { + tagName: 'a.remove-invited', + + html() { + return iconNode('times'); + }, + + click() { + bootbox.confirm(I18n.t("private_message_info.remove_allowed_user", {name: this.attrs.username}), + () => this.sendWidgetAction('removeAllowedUser', this.attrs) + ); + } +}); + +createWidget('pm-map-user', { + tagName: 'div.user', + + html(attrs) { + const user = attrs.user; + const avatar = avatarFor('small', { template: user.avatar_template, username: user.username }); + const link = h('a', { attributes: { href: user.get('path') } }, [ avatar, ' ', user.username ]); + + const result = [link]; + if (attrs.canRemoveAllowedUsers) { + result.push(' '); + result.push(this.attach('pm-remove-link', user)); + } + + return result; + } +}); + +export default createWidget('private-message-map', { + tagName: 'section.information.private-message-map', + + html(attrs) { + const participants = []; + + if (attrs.allowedGroups.length) { + participants.push(attrs.allowedGroups.map(ag => this.attach('pm-map-user-group', ag))); + } + + if (attrs.allowedUsers.length) { + participants.push(attrs.allowedUsers.map(ag => { + return this.attach('pm-map-user', { user: ag, canRemoveAllowedUsers: attrs.canRemoveAllowedUsers }); + })); + } + + const result = [ h('h3', [iconNode('envelope'), ' ', I18n.t('private_message_info.title')]), + h('div.participants.clearfix', participants) ]; + + if (attrs.canInvite) { + result.push(h('div.controls', this.attach('button', { + action: 'showInvite', + label: 'private_message_info.invite', + className: 'btn' + }))); + } + + return result; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/raw-html.js.es6 b/app/assets/javascripts/discourse/widgets/raw-html.js.es6 new file mode 100644 index 000000000..a1eddd74b --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/raw-html.js.es6 @@ -0,0 +1,18 @@ +export default class RawHtml { + constructor(attrs) { + this.html = attrs.html; + } + + init() { + return $(this.html)[0]; + } + + update(prev) { + if (prev.html === this.html) { return; } + return this.init(); + } + + destroy() { } +} + +RawHtml.prototype.type = 'Widget'; diff --git a/app/assets/javascripts/discourse/widgets/time-gap.js.es6 b/app/assets/javascripts/discourse/widgets/time-gap.js.es6 new file mode 100644 index 000000000..cb1b55810 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/time-gap.js.es6 @@ -0,0 +1,26 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { iconNode } from 'discourse/helpers/fa-icon'; + +function description(attrs) { + const daysSince = attrs.daysSince; + + if (daysSince < 30) { + return I18n.t('dates.later.x_days', {count: daysSince}); + } else if (daysSince < 365) { + const gapMonths = Math.floor(daysSince / 30); + return I18n.t('dates.later.x_months', {count: gapMonths}); + } else { + const gapYears = Math.floor(daysSince / 365); + return I18n.t('dates.later.x_years', {count: gapYears}); + } +} + +export default createWidget('time-gap', { + tagName: 'div.time-gap.small-action', + + html(attrs) { + return [h('div.topic-avatar', iconNode('clock-o')), + h('div.small-action-desc', description(attrs))]; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 b/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 new file mode 100644 index 000000000..33580af7f --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 @@ -0,0 +1,33 @@ +import RawHtml from 'discourse/widgets/raw-html'; +import { createWidget } from 'discourse/widgets/widget'; + +createWidget('toggle-summary-description', { + description(attrs) { + if (attrs.topicSummaryEnabled) { + return I18n.t('summary.enabled_description'); + } + + if (attrs.topicWordCount) { + const readingTime = Math.floor(attrs.topicWordCount / this.siteSettings.read_time_word_count); + return I18n.t('summary.description_time', { count: attrs.topicPostsCount, readingTime }); + } + return I18n.t('summary.description', { count: attrs.topicPostsCount }); + }, + + html(attrs) { + // vdom makes putting html in the i18n difficult + return new RawHtml({ html: `

${this.description(attrs)}

` }); + } +}); + +export default createWidget('toggle-topic-summary', { + tagName: 'section.information.toggle-summary', + html(attrs) { + return [ this.attach('toggle-summary-description', attrs), + this.attach('button', { + className: 'btn btn-primary', + label: attrs.topicSummaryEnabled ? 'summary.disable' : 'summary.enable', + action: 'toggleSummary' + }) ]; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/topic-map.js.es6 b/app/assets/javascripts/discourse/widgets/topic-map.js.es6 new file mode 100644 index 000000000..00d6f7e93 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/topic-map.js.es6 @@ -0,0 +1,219 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { avatarImg, avatarFor } from 'discourse/widgets/post'; +import { dateNode, numberNode } from 'discourse/helpers/node'; + +const LINKS_SHOWN = 5; + +function renderParticipants(userFilters, participants) { + if (!participants) { return; } + + userFilters = userFilters || []; + return participants.map(p => { + return this.attach('topic-participant', p, { state: { toggled: userFilters.contains(p.username) } }); + }); +} + +createWidget('topic-map-show-links', { + tagName: 'div.link-summary', + html(attrs) { + return h('a', I18n.t('topic_map.links_shown', { totalLinks: attrs.totalLinks })); + }, + + click() { + this.sendWidgetAction('showAllLinks'); + } +}); + +createWidget('topic-participant', { + html(attrs, state) { + const linkContents = [avatarImg('medium', { username: attrs.username, template: attrs.avatar_template })]; + + if (attrs.post_count > 2) { + linkContents.push(h('span.post-count', attrs.post_count.toString())); + } + + return h('a.poster', { className: state.toggled ? 'toggled' : null, attributes: { title: attrs.username } }, + linkContents + ); + }, + + click() { + this.sendWidgetAction('toggleParticipant', this.attrs); + } +}); + +createWidget('topic-map-summary', { + tagName: 'section.map', + + buildClasses(attrs, state) { + if (state.collapsed) { return 'map-collapsed'; } + }, + + html(attrs, state) { + const contents = []; + contents.push(h('li', + [ + h('h4', I18n.t('created_lowercase')), + avatarFor('tiny', { username: attrs.createdByUsername, template: attrs.createdByAvatarTemplate }), + dateNode(attrs.topicCreatedAt) + ] + )); + contents.push(h('li', + h('a', { attributes: { href: attrs.lastPostUrl } }, [ + h('h4', I18n.t('last_reply_lowercase')), + avatarFor('tiny', { username: attrs.lastPostUsername, template: attrs.lastPostAvatarTemplate }), + dateNode(attrs.lastPostAt) + ]) + )); + contents.push(h('li', [ + numberNode(attrs.topicReplyCount), + h('h4', I18n.t('replies_lowercase', { count: attrs.topicReplyCount })) + ])); + contents.push(h('li.secondary', [ + numberNode(attrs.topicViews, { className: attrs.topicViewsHeat }), + h('h4', I18n.t('views_lowercase', { count: attrs.topicViews })) + ])); + contents.push(h('li.secondary', [ + numberNode(attrs.participantCount), + h('h4', I18n.t('users_lowercase', { count: attrs.participantCount })) + ])); + + if (attrs.topicLikeCount) { + contents.push(h('li.secondary', [ + numberNode(attrs.likeCount), + h('h4', I18n.t('likes_lowercase', { count: attrs.likeCount })) + ])); + } + + if (attrs.topicLinkLength > 0) { + contents.push(h('li.secondary', [ + numberNode(attrs.topicLinkLength), + h('h4', I18n.t('links_lowercase', { count: attrs.topicLinkLength })) + ])); + } + + if (state.collapsed && attrs.topicPostsCount > 2 && attrs.participants.length > 0) { + const participants = renderParticipants.call(this, attrs.userFilters, attrs.participants.slice(0, 3)); + contents.push(h('li.avatars', participants)); + } + + return h('ul.clearfix', contents); + } +}); + +createWidget('topic-map-link', { + tagName: 'a.topic-link.track-link', + + buildClasses(attrs) { + if (attrs.attachment) { return 'attachment'; } + }, + + buildAttributes(attrs) { + return { href: attrs.url, + target: "_blank", + 'data-user-id': attrs.user_id, + 'data-ignore-post-id': 'true', + title: attrs.url }; + }, + + html(attrs) { + if (attrs.title) { return attrs.title; } + return attrs.url; + } +}); + +createWidget('topic-map-expanded', { + tagName: 'section.topic-map-expanded', + + defaultState() { + return { allLinksShown: false }; + }, + + html(attrs, state) { + const avatars = h('section.avatars.clearfix', [ + h('h3', I18n.t('topic_map.participants_title')), + renderParticipants.call(this, attrs.userFilters, attrs.participants) + ]); + + const result = [avatars]; + if (attrs.topicLinks) { + + const toShow = state.allLinksShown ? attrs.topicLinks : attrs.topicLinks.slice(0, LINKS_SHOWN); + const links = toShow.map(l => { + + let host = ''; + if (l.title && l.title.length) { + const domain = l.domain; + if (domain && domain.length) { + const s = domain.split('.'); + host = h('span.domain', s[s.length-2] + "." + s[s.length-1]); + } + } + + return h('tr', [ + h('td', + h('span.badge.badge-notification.clicks', { + attributes: { title: I18n.t('topic_map.clicks', { count: l.clicks }) } + }, l.clicks.toString()) + ), + h('td', [this.attach('topic-map-link', l), ' ', host]) + ]); + }); + + const showAllLinksContent = [ + h('h3', I18n.t('topic_map.links_title')), + h('table.topic-links', links) + ]; + + if (!state.allLinksShown && links.length < attrs.topicLinks.length) { + showAllLinksContent.push(this.attach('topic-map-show-links', { totalLinks: attrs.topicLinks.length })); + } + + const section = h('section.links', showAllLinksContent); + result.push(section); + } + return result; + }, + + showAllLinks() { + this.state.allLinksShown = true; + } +}); + +export default createWidget('topic-map', { + tagName: 'div.topic-map', + buildKey: attrs => `topic-map-${attrs.id}`, + + defaultState() { + return { collapsed: true }; + }, + + html(attrs, state) { + const nav = h('nav.buttons', this.attach('button', { + title: 'topic.toggle_information', + icon: state.collapsed ? 'chevron-down' : 'chevron-up', + action: 'toggleMap', + className: 'btn', + })); + + const contents = [nav, this.attach('topic-map-summary', attrs, { state })]; + + if (!state.collapsed) { + contents.push(this.attach('topic-map-expanded', attrs)); + } + + if (attrs.hasTopicSummary) { + contents.push(this.attach('toggle-topic-summary', attrs)); + } + + if (attrs.showPMMap) { + contents.push(this.attach('private-message-map', attrs)); + } + return contents; + }, + + toggleMap() { + this.state.collapsed = !this.state.collapsed; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6 new file mode 100644 index 000000000..aeff96192 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -0,0 +1,214 @@ +import { WidgetClickHook, WidgetClickOutsideHook } from 'discourse/widgets/click-hook'; +import { h } from 'virtual-dom'; + +function emptyContent() { } + +const _registry = {}; +const _dirty = {}; + +export function keyDirty(key) { + _dirty[key] = true; +} + +function drawWidget(builder, attrs, state) { + const properties = {}; + + if (this.buildClasses) { + let classes = this.buildClasses(attrs, state) || []; + if (!Array.isArray(classes)) { classes = [classes]; } + if (classes.length) { + properties.className = classes.join(' '); + } + } + if (this.buildId) { + properties.id = this.buildId(attrs); + } + + if (this.buildAttributes) { + properties.attributes = this.buildAttributes(attrs); + } + if (this.clickOutside) { + properties['widget-click-outside'] = new WidgetClickOutsideHook(this); + } + if (this.click) { + properties['widget-click'] = new WidgetClickHook(this); + } + + const attributes = properties['attributes'] || {}; + properties.attributes = attributes; + if (this.title) { + attributes.title = I18n.t(this.title); + } + + return h(this.tagName || 'div', properties, this.html(attrs, state)); +} + +export function createWidget(name, opts) { + const result = class CustomWidget extends Widget {}; + + if (name) { + _registry[name] = result; + } + + opts.html = opts.html || emptyContent; + opts.draw = drawWidget; + + Object.keys(opts).forEach(k => result.prototype[k] = opts[k]); + return result; +} + +export default class Widget { + constructor(attrs, container, opts) { + opts = opts || {}; + this.attrs = attrs || {}; + this.mergeState = opts.state; + this.container = container; + this.model = opts.model; + + this.key = this.buildKey ? this.buildKey(attrs) : null; + + this.site = container.lookup('site:main'); + this.siteSettings = container.lookup('site-settings:main'); + this.currentUser = container.lookup('current-user:main'); + this.store = container.lookup('store:main'); + } + + defaultState() { + return {}; + } + + destroy() { + console.log('destroy called'); + } + + render(prev) { + if (prev && prev.state) { + this.state = prev.state; + } else { + this.state = this.defaultState(); + } + + // Sometimes we pass state down from the parent + if (this.mergeState) { + this.state = _.merge(this.state, this.mergeState); + } + + if (prev && prev.shadowTree) { + this.shadowTree = true; + if (!_dirty[prev.key]) { return prev.vnode; } + } + + return this.draw(h, this.attrs, this.state); + } + + _findAncestorWithProperty(property) { + let widget = this; + while (widget) { + const value = widget[property]; + if (value) { + return widget; + } + widget = widget.parentWidget; + } + } + + _findView() { + const widget = this._findAncestorWithProperty('_emberView'); + if (widget) { + return widget._emberView; + } + } + + attach(widgetName, attrs, opts) { + let WidgetClass = _registry[widgetName]; + + if (!WidgetClass) { + if (!this.container) { + console.error("couldn't find container"); + return; + } + WidgetClass = this.container.lookupFactory(`widget:${widgetName}`); + } + + if (WidgetClass) { + const result = new WidgetClass(attrs, this.container, opts); + result.parentWidget = this; + return result; + } else { + throw `Couldn't find ${widgetName} factory`; + } + } + + scheduleRerender() { + let widget = this; + while (widget) { + if (widget.shadowTree) { + keyDirty(widget.key); + } + + const emberView = widget._emberView; + if (emberView) { + return emberView.queueRerender(); + } + widget = widget.parentWidget; + } + } + + sendComponentAction(name, param) { + const view = this._findAncestorWithProperty('_emberView'); + + let promise; + if (view) { + // Peek into ember internals to allow us to return promises from actions + const ev = view._emberView; + const target = ev.get('targetObject'); + + const actionName = ev.get(name); + if (!actionName) { + Ember.warn(`${name} not found`); + return; + } + + if (target) { + const actions = target._actions || target.actionHooks; + const method = actions[actionName]; + if (method) { + promise = method.call(target, param); + if (!promise || !promise.then) { + promise = Ember.RSVP.resolve(promise); + } + } else { + return ev.sendAction(name, param); + } + } + } + + if (promise) { + return promise.then(() => this.scheduleRerender()); + } + } + + findAncestorModel() { + const modelWidget = this._findAncestorWithProperty('model'); + if (modelWidget) { + return modelWidget.model; + } + } + + sendWidgetAction(name, param) { + const widget = this._findAncestorWithProperty(name); + if (widget) { + const result = widget[name](param); + if (result && result.then) { + return result.then(() => this.scheduleRerender()); + } else { + this.scheduleRerender(); + return result; + } + } + + return this.sendComponentAction(name, param || this.findAncestorModel()); + } +} + +Widget.prototype.type = 'Thunk'; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 43379cc09..dd6a27e13 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -58,11 +58,9 @@ //= require ./discourse/models/user-badge //= require ./discourse/controllers/discovery-sortable //= require ./discourse/controllers/navigation/default -//= require ./discourse/views/grouped //= require ./discourse/views/container //= require ./discourse/views/modal-body //= require ./discourse/views/flag -//= require ./discourse/views/cloaked //= require ./discourse/components/combo-box //= require ./discourse/components/edit-category-panel //= require ./discourse/views/button @@ -110,3 +108,4 @@ //= require_tree ./discourse/pre-initializers //= require_tree ./discourse/initializers //= require_tree ./discourse/services +//= require_tree ./discourse/widgets diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index 29ee2c532..d2143d320 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -38,4 +38,6 @@ //= require break_string //= require buffered-proxy //= require jquery.autoellipsis-1.0.10.min.js +//= require virtual-dom +//= require virtual-dom-amd //= require_tree ./discourse/ember diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index e5db38f83..0d4aa675d 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -19,7 +19,7 @@ h1 .topic-statuses .topic-status i { max-height: 40px; } -.post-cloak { +.topic-body { padding: 0; &:first-of-type { @@ -208,7 +208,6 @@ nav.post-controls { bottom: -2px; right: 15px; z-index: 1000; - display: none; h3 { margin-top: 0; @@ -1003,9 +1002,9 @@ and (max-width : 870px) { width: 45px; } - .post-cloak .reply-to-tab { + .topic-post .reply-to-tab { right: 15%; - } + } .topic-body { box-sizing: border-box; diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 75b772802..258307305 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -163,7 +163,7 @@ position: absolute; } -.post-cloak:last-of-type {padding-bottom: 40px;} +.topic-post:last-of-type {padding-bottom: 40px;} .heatmap-high {color: scale-color($danger, $lightness: -25%) !important;} .heatmap-med {color: $danger !important;} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 97dbc78ac..5ae311e51 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1530,17 +1530,14 @@ en: like: "Undo like" vote: "Undo vote" people: - off_topic: "{{icons}} flagged this as off-topic" - spam: "{{icons}} flagged this as spam" - spam_with_url: "{{icons}} flagged this as spam" - inappropriate: "{{icons}} flagged this as inappropriate" - notify_moderators: "{{icons}} notified moderators" - notify_moderators_with_url: "{{icons}} notified moderators" - notify_user: "{{icons}} sent a message" - notify_user_with_url: "{{icons}} sent a message" - bookmark: "{{icons}} bookmarked this" - like: "{{icons}} liked this" - vote: "{{icons}} voted for this" + off_topic: "flagged this as off-topic" + spam: "flagged this as spam" + inappropriate: "flagged this as inappropriate" + notify_moderators: "notified moderators" + notify_user: "sent a message" + bookmark: "bookmarked this" + like: "liked this" + vote: "voted for this" by_you: off_topic: "You flagged this as off-topic" spam: "You flagged this as spam" diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index a63a9a598..9cfde3e21 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -928,7 +928,6 @@ ar: suppress_reply_directly_above: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب." suppress_reply_when_quoting: "لا تظهر للتوسع في الرد لعلى وظيفة عندما يكون هناك سوى رد واحد مباشرة فوق هذا المنصب." max_reply_history: "الحد الأقصى لعدد الردود على توسيع عند توسيع في الرد ل" - experimental_reply_expansion: "إخفاء ردود المتوسطة عند توسيع ردا على (تجريبي)" topics_per_period_in_top_summary: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع." topics_per_period_in_top_page: "عدد من أهم الموضوعات هو مبين في موجز أعلى الافتراضية المواضيع." redirect_users_to_top_page: "إعادة توجيه تلقائيا للمستخدمين الجدد وغائبة لمدة طويلة إلى أعلى الصفحة." diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 66051a31f..f3d2eb192 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -758,7 +758,6 @@ de: suppress_reply_directly_above: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der beantwortete Beitrag direkt darüber angezeigt wird." suppress_reply_when_quoting: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der Beitrag den beantworteten Beitrag zitiert." max_reply_history: "Maximale Anzahl an Antworten beim Ausklappen von in-reply-to" - experimental_reply_expansion: "Verstecke dazwischenliegende Beiträge, wenn der beantwortete Beitrag erweitert wird (experimentell)." topics_per_period_in_top_summary: "Anzahl der Themen, die in der Top-Themübersicht angezeigt werden." topics_per_period_in_top_page: "Anzahl der Themen, die in der mit \"Mehr zeigen\" erweiterten Top-Themenübersicht angezeigt werden." redirect_users_to_top_page: "Verweise neue und länger abwesende Nutzer automatisch zur Top Übersichtsseite" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f0f4c26cd..7c1a72820 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -877,9 +877,6 @@ en: suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post." suppress_reply_when_quoting: "Don't show the expandable in-reply-to on a post when post quotes reply." max_reply_history: "Maximum number of replies to expand when expanding in-reply-to" - - experimental_reply_expansion: "Hide intermediate replies when expanding a reply to (experimental)" - topics_per_period_in_top_summary: "Number of top topics shown in the default top topics summary." topics_per_period_in_top_page: "Number of top topics shown on the expanded 'Show More' top topics." redirect_users_to_top_page: "Automatically redirect new and long absent users to the top page." diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index e4cd55c40..5cb5d2a30 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -773,7 +773,6 @@ es: suppress_reply_directly_above: "No mostrar el en-respuesta-a desplegable en un post cuando solo hay una sola respuesta justo encima del post." suppress_reply_when_quoting: "No mostrar el desplegable en-respuesta-a en un post cuando el post cite la respuesta." max_reply_history: "Número máximo de respuestas a mostrar al expandir en-respuesta-a" - experimental_reply_expansion: "Ocultar respuestas intermedias cuando se expande una respuesta (experimental)" topics_per_period_in_top_summary: "Número de mejores temas mostrados en el resumen de mejores temas." topics_per_period_in_top_page: "Número de mejores temas mostrados en la vista expandida al clicar en 'ver más'." redirect_users_to_top_page: "Redirigir automáticamente a los nuevos usuarios y a los ausentes de larga duración a la página de mejores temas." diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index 5b66093ba..d96cbb7f1 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -699,7 +699,6 @@ fa_IR: suppress_reply_directly_above: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی فقط یک پاسخ بالای این نوشته است." suppress_reply_when_quoting: "in-reply-to قابل بزرگ شدن را نشان نده در یک نوشته وقتی به یک نوشته پاسخ داده می شود." max_reply_history: "حداکثر تعداد پاسخ ها به توسعه زمان گسترش in-reply-to" - experimental_reply_expansion: "پاسخ های میانی را مخفی کن زمان توسعه یک پاسخ به (آزمایشی) " topics_per_period_in_top_summary: "تعداد بهتریت جستارهای نشان داده شده در بخش پیش فرض خلاصه بهترین جستارها." topics_per_period_in_top_page: "تعداد جستارهای خوب نشان داده شود در بخش گسترش یافته \" بیشتر نشان بده\" بهترین جستارها. " redirect_users_to_top_page: "بطور خودکار کاربران جدید و کاربران غایب را به بهترین صفحه هدایت کن." diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 48c387f36..143c58ccf 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -781,7 +781,6 @@ fi: suppress_reply_directly_above: "Älä näytä vastauksena-painiketta viestin yläreunassa, jos viestissä on vastattu vain edelliseen viestiin." suppress_reply_when_quoting: "Älä näytä vastauksena-painiketta viestin yläreunassa, kun viestissä on lainaus." max_reply_history: "Maksimimäärä vastauksia, jotka avataan klikattaessa 'vastauksena' painiketta" - experimental_reply_expansion: "Piilota välilliset vastaukset, kun 'vastauksena' avataan (kokeellinen)" topics_per_period_in_top_summary: "Ketjujen lukumäärä, joka näytetään oletuksena Huiput-listauksissa." topics_per_period_in_top_page: "Ketjujen lukumäärä, joka näytetään laajennetussa Huiput-listauksessa." redirect_users_to_top_page: "Ohjaa uudet ja kauan poissa olleet käyttäjät automaattisesti huiput-sivulle." diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index d07d23a93..105b6be96 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -775,7 +775,6 @@ fr: suppress_reply_directly_above: "Ne pas afficher 'en réponse à' sur un message quand la seule réponse est juste en dessus de ce dernier." suppress_reply_when_quoting: "Ne pas affiché le panneau \"En réponse à\" sur un message qui répond à une citation." max_reply_history: "Nombre maximum de réponses à développer lors du développement d'une \"réponse à\"" - experimental_reply_expansion: "Masquer les réponses intermédiaires lors de l'ouverture d'une répondre à (expérimental)" topics_per_period_in_top_summary: "Nombre de meilleurs sujets affichés dans le résumé par défaut des meilleurs sujets." topics_per_period_in_top_page: "Nombre de meilleurs sujets affichés lorsqu'on sélectionne \"Voir plus\" des meilleurs sujets." redirect_users_to_top_page: "Rediriger automatiquement les nouveaux utilisateurs et les longues absences sur la page Top." diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 008299cdd..e79e6d50e 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -727,7 +727,6 @@ he: suppress_reply_directly_above: "אל תציגו את את אפשרות ההרחבה \"בתגובה ל..\" לפרסום כאשר יש רק תגובה אחת ישירה מעל לפרסום זה." suppress_reply_when_quoting: "אל תציגו את הפרסום המקורי בפרסומים שמצטטים תגובות" max_reply_history: "מספר התגובות המקסימלי להרחבה כאשר מרחיבים \"בתגובה ל\"" - experimental_reply_expansion: "החבא תגובות ביניים כאשר מרחיבים תגובה (ניסיוני)" topics_per_period_in_top_summary: "מספר הנושאים המוצגים בבריכת המחדל של סיכום הנושאים." topics_per_period_in_top_page: "מספר הנושאים הראשונים המוצגים בתצוגה המורחבת של \"הצג עוד\"." redirect_users_to_top_page: "כוון באופן אוטומטי משתמשים חדשים וכאלה שנעדרו במשך זמן לראש העמוד." diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index 3d12040e8..969ba7c39 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -728,7 +728,6 @@ it: suppress_reply_directly_above: "Non mostrare in-risposta-a espandibile in un messaggio quando c'è una sola risposta sopra quel messaggio. " suppress_reply_when_quoting: "Non mostrare in-risposta-a espandibile in un messaggio quando il messaggio include la citazione." max_reply_history: "Numero massimo di risposte da espandere quando si espande in-risposta-a" - experimental_reply_expansion: "Nascondi le risposte intermedie quando si espande una risposta (sperimentale)" topics_per_period_in_top_summary: "Numero di argomenti di punta mostrati nel riepilogo di default." topics_per_period_in_top_page: "Numero di argomenti di punta mostrati nella vista espansa 'Mostra Altro'" redirect_users_to_top_page: "Redirigi automaticamente i nuovi utenti e quelli assenti da tempo sulla pagina degli argomenti di punta." diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index dcfe7301d..0d4381c44 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -624,7 +624,6 @@ ja: suppress_reply_directly_above: "ポストに回答が1つしかない場合、ポストのin-reply-toを表示しない" suppress_reply_when_quoting: "ポストが引用返信だった場合、ポストのin-reply-toを表示しない" max_reply_history: "回答のin-reply-toを展開する最大数" - experimental_reply_expansion: "回答を展開するときに、その間にある回答を非表示にする(実験)" topics_per_period_in_top_summary: "デフォルトのトピックサマリに表示されるトップトピックの数" topics_per_period_in_top_page: "'もっと見る'を展開したときに表示するトップトピックの数" redirect_users_to_top_page: "新規ユーザーと長く不在のユーザーをトップページに自動的にリダイレクトさせる" diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 14b7ff0b0..8c7773edd 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -643,7 +643,6 @@ ko: suppress_reply_directly_above: "단 하나의 댓글 위의 글이 하나 있는 상황에서 '~에 대한 댓글'을 보여주지 않음." suppress_reply_when_quoting: "글안에 답글이 인용될 때 in-reply-to를 보여주지 않습니다." max_reply_history: "덧글 확장해서 보여지는 최대 갯수" - experimental_reply_expansion: "답글을 펼쳤을 때 중간 답글을 숨깁니다(실험 중)" topics_per_period_in_top_summary: "인기 글타래 요약에 기본으로 보여질 글타래 수" topics_per_period_in_top_page: "인기 글타래에서 '더 보기'를 요청할 시 보여질 글타래 수" redirect_users_to_top_page: "자동으로 신규 사용자와 오래간만에 들어온 사용자를 탑 페이지로 리다이렉트 시킴" diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index b1f023945..75d719945 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -746,7 +746,6 @@ nl: suppress_reply_directly_above: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er maar één reactie direct boven staat." suppress_reply_when_quoting: "Verberg de uitklapbare 'in antwoord op' knop bij een bericht als er er gequoteerd is." max_reply_history: "Maximaal aantal uit te klappen antwoorden als een 'in antwoord op' uitgeklapt wordt." - experimental_reply_expansion: "Verberg tussenliggende antwoorden als een antwoord uitgeklapt wordt (experimenteel)" topics_per_period_in_top_summary: "Aantal topics in het top-topics overzicht" topics_per_period_in_top_page: "Aantal topics in het uitgeklapte ´Meer...´ top-topics overzicht" redirect_users_to_top_page: "Stuur nieuwe en lang-niet-geziene gebruikers door naar de top-pagina" diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index f90e50406..adabb2c27 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -781,7 +781,6 @@ pt: suppress_reply_directly_above: "Não mostrar em-resposta-a expansível quando há apenas uma única resposta diretamente acima desta publicação." suppress_reply_when_quoting: "Não mostraR em-resposta-a expansível numa mensagem quando esta cita uma resposta." max_reply_history: "Número máximo de respostas a serem expandidas quando se expande em-resposta-a" - experimental_reply_expansion: "Esconder respostas intermédias ao expandir uma resposta-a (experimental)" topics_per_period_in_top_summary: "Número de tópicos principais mostrados no resumo padrão de tópicos principais." topics_per_period_in_top_page: "Número de tópicos principais mostrados em 'Mostrar Mais' tópicos principais expandido." redirect_users_to_top_page: "Redirecionar automaticamente os utilizadores novos e ausentes por períodos longos para o topo da página." diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index 160aca462..d21721249 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -747,7 +747,6 @@ pt_BR: suppress_reply_directly_above: "Não mostrar a em-resposta-à expansível em um post quando há apenas uma única resposta diretamente acima deste post." suppress_reply_when_quoting: "Não mostrar a em-resposta-à expansível em um post quando o post cita a resposta." max_reply_history: "Número máximo de respostas para expandir quando expandindo em-resposta-à" - experimental_reply_expansion: "Esconder respostas intermediárias quando expandindo uma resposta à (experimental)" topics_per_period_in_top_summary: "Número de melhores tópicos mostrados no sumário padrão de melhores tópicos." topics_per_period_in_top_page: "Número de melhores tópicos mostrados no 'Exibir Mais' melhores tópicos quando expandido." redirect_users_to_top_page: "Automaticamente redirecionar usuários novos e há muito ausentes para a página melhores." diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index 415b548b5..c3b75bbc7 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -767,7 +767,6 @@ ru: suppress_reply_directly_above: "Не показывать разворачивемый блок \"в ответ на\" для сообщения, если есть всего лишь одно сообщение непосредственно выше." suppress_reply_when_quoting: "Не показывать разворачивемый блок \"в ответ на\", если сообщение уже содержит цитату." max_reply_history: "Максимальное число разворачивающихся ответов в блоке \"в ответ на\"" - experimental_reply_expansion: "Скрыть промежуточные ответы, когда раскрывается все ответы (эксперементальная функция)" topics_per_period_in_top_summary: "Количество рекомендованных тем, отображаемых внизу текущей темы." topics_per_period_in_top_page: "Количество рекомендованных тем, отображаемых при нажатии 'Показать больше' в низу текущей темы." redirect_users_to_top_page: "Автоматически перенаправлять новых и давно отсутствующих пользователей к началу страницы." diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml index 63eb1ece9..a2c0e7846 100644 --- a/config/locales/server.sq.yml +++ b/config/locales/server.sq.yml @@ -698,7 +698,6 @@ sq: suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post." suppress_reply_when_quoting: "Don't show the expandable in-reply-to on a post when post quotes reply." max_reply_history: "Maximum number of replies to expand when expanding in-reply-to" - experimental_reply_expansion: "Hide intermediate replies when expanding a reply to (experimental)" topics_per_period_in_top_summary: "Number of top topics shown in the default top topics summary." topics_per_period_in_top_page: "Number of top topics shown on the expanded 'Show More' top topics." redirect_users_to_top_page: "Automatically redirect new and long absent users to the top page." diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index c56bf44e2..0f044e9a0 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -713,7 +713,6 @@ tr_TR: suppress_reply_directly_above: "Bu gönderinin direk üstünde sadece tek bir cevap varsa, gönderideki açılabilir hangi-cevaba-istinaden-cevapla bölümünü gösterme." suppress_reply_when_quoting: "Gönderi cevabı alıntılarsa, gönderideki açılabilir hangi-cevaba-istinaden-cevapla bölümünü gösterme." max_reply_history: "Hangi-cevaba-istinaden-cevapla bölümü açılınca gösterilecek en fazla cevap sayısı" - experimental_reply_expansion: "Hangi-cevaba-istinaden-cevapla bölümü açılınca ara cevapları gizle (deneysel)" topics_per_period_in_top_summary: "Popüler konular özetinde gösterilen popüler konu sayısı." topics_per_period_in_top_page: "'Daha Fazla Göster' ile genişletilen popüler konular bölümünde gösterilecek popüler konu sayısı. " redirect_users_to_top_page: "Yeni ve uzun süredir giriş yapmamış kullanıcıları otomatik olarak Popüler sayfasına yönlendir." diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index eda4df6eb..d6be75f4a 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -736,7 +736,6 @@ zh_CN: suppress_reply_directly_above: "当一个帖子只有一个回复时,不显示回复到该贴的回复。" suppress_reply_when_quoting: "当帖子引用回复时,不显示可展开的回复到某贴的标记。" max_reply_history: "扩展回复至时显示的最大回复数量" - experimental_reply_expansion: "当展开回复至内容时隐藏直接回复(实验性)" topics_per_period_in_top_summary: "在一个主题底部显示的默认推荐主题的数量。" topics_per_period_in_top_page: "在展开“显示更多”推荐主题列表显示的主题数量。" redirect_users_to_top_page: "自动重定向至新用户或者长时间未登入的用户至热门页面。" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index 04ee6a797..2a93ce012 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -588,7 +588,6 @@ zh_TW: suppress_reply_directly_above: "當帖子只有一個回覆時,不顯示回覆到該帖的回覆。" suppress_reply_when_quoting: "當帖子引用回覆時,不顯示可展開的回覆到某帖的標記。" max_reply_history: "擴展回覆到某帖時顯示的最大回覆數量" - experimental_reply_expansion: "當展開回覆到某帖時隱藏直接回覆(實驗性)" topics_per_period_in_top_summary: "預設推薦話題的顯示數量" topics_per_period_in_top_page: "在展開 \"顯示更多\" 推薦話題列表的顯示數量" redirect_users_to_top_page: "將新用戶或長時間未使用的用戶自動重新導向至熱門頁面" diff --git a/config/site_settings.yml b/config/site_settings.yml index 6937a8de3..b95667162 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -430,9 +430,6 @@ posting: max_reply_history: default: 1 client: true - experimental_reply_expansion: - default: false - client: true post_undo_action_window_mins: 10 max_mentions_per_post: 10 max_users_notified_per_group_mention: 100 diff --git a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb index 169985693..d54524fe6 100644 --- a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb +++ b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb @@ -143,7 +143,7 @@ module Tilt def generate_source(scope) js_source = ::JSON.generate(data, quirks_mode: true) - js_source = "babel.transform(#{js_source}, {ast: false, whitelist: ['es6.constants', 'es6.properties.shorthand', 'es6.arrowFunctions', 'es6.blockScoping', 'es6.destructuring', 'es6.spread', 'es6.parameters', 'es6.templateLiterals', 'es6.regex.unicode', 'es7.decorators']})['code']" + js_source = "babel.transform(#{js_source}, {ast: false, whitelist: ['es6.constants', 'es6.properties.shorthand', 'es6.arrowFunctions', 'es6.blockScoping', 'es6.destructuring', 'es6.spread', 'es6.parameters', 'es6.templateLiterals', 'es6.regex.unicode', 'es7.decorators', 'es6.classes']})['code']" "new module.exports.Compiler(#{js_source}, '#{module_name(scope.root_path, scope.logical_path)}', #{compiler_options}).#{compiler_method}()" end diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 index e8d8e8dbf..38789579c 100644 --- a/test/javascripts/acceptance/composer-test.js.es6 +++ b/test/javascripts/acceptance/composer-test.js.es6 @@ -192,8 +192,8 @@ test("Edit the first post", () => { ok(!exists('.topic-post:eq(0) .post-info.edits'), 'it has no edits icon at first'); - click('.topic-post:eq(0) button[data-action=showMoreActions]'); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.show-more-actions'); + click('.topic-post:eq(0) button.edit'); andThen(() => { equal(find('.d-editor-input').val().indexOf('Any plans to support'), 0, 'it populates the input with the post text'); }); @@ -212,11 +212,11 @@ test("Edit the first post", () => { test("Composer can switch between edits", () => { visit("/t/this-is-a-test-topic/9"); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); andThen(() => { equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); - click('.topic-post:eq(1) button[data-action=edit]'); + click('.topic-post:eq(1) button.edit'); andThen(() => { equal(find('.d-editor-input').val().indexOf('This is the second post.'), 0, 'it populates the input with the post text'); }); @@ -225,9 +225,9 @@ test("Composer can switch between edits", () => { test("Composer with dirty edit can toggle to another edit", () => { visit("/t/this-is-a-test-topic/9"); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); fillIn('.d-editor-input', 'This is a dirty reply'); - click('.topic-post:eq(1) button[data-action=edit]'); + click('.topic-post:eq(1) button.edit'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); }); @@ -240,15 +240,15 @@ test("Composer with dirty edit can toggle to another edit", () => { test("Composer can toggle between edit and reply", () => { visit("/t/this-is-a-test-topic/9"); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); andThen(() => { equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); - click('.topic-post:eq(0) button[data-action=reply]'); + click('.topic-post:eq(0) button.reply'); andThen(() => { equal(find('.d-editor-input').val(), "", 'it clears the input'); }); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); andThen(() => { equal(find('.d-editor-input').val().indexOf('This is the first post.'), 0, 'it populates the input with the post text'); }); @@ -257,9 +257,9 @@ test("Composer can toggle between edit and reply", () => { test("Composer with dirty reply can toggle to edit", () => { visit("/t/this-is-a-test-topic/9"); - click('.topic-post:eq(0) button[data-action=reply]'); + click('.topic-post:eq(0) button.reply'); fillIn('.d-editor-input', 'This is a dirty reply'); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); }); @@ -272,10 +272,10 @@ test("Composer with dirty reply can toggle to edit", () => { test("Composer draft with dirty reply can toggle to edit", () => { visit("/t/this-is-a-test-topic/9"); - click('.topic-post:eq(0) button[data-action=reply]'); + click('.topic-post:eq(0) button.reply'); fillIn('.d-editor-input', 'This is a dirty reply'); click('.toggler'); - click('.topic-post:eq(0) button[data-action=edit]'); + click('.topic-post:eq(0) button.edit'); andThen(() => { ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); }); diff --git a/test/javascripts/components/post-menu-test.js.es6 b/test/javascripts/components/post-menu-test.js.es6 deleted file mode 100644 index a027bb660..000000000 --- a/test/javascripts/components/post-menu-test.js.es6 +++ /dev/null @@ -1,52 +0,0 @@ -import componentTest from 'helpers/component-test'; - -moduleForComponent('post-menu', {integration: true}); - -function setup(store) { - const topic = store.createRecord('topic', {id: 123}); - const post = store.createRecord('post', { - id: 1, - post_number: 1, - topic, - like_count: 3, - actions_summary: [ - {id: 2, count: 3, hidden: false, can_act: true} - ] - }); - - this.on('toggleLike', function() { - post.toggleProperty('likeAction.acted'); - }); - - this.set('post', post); -} - -componentTest('basic render', { - template: '{{post-menu post=post}}', - setup, - test(assert) { - assert.ok(!!this.$('.post-menu-area').length, 'it renders a post menu'); - assert.ok(!!this.$('.actions button[data-share-url]').length, 'it renders a share button'); - } -}); - -componentTest('liking', { - template: '{{post-menu post=post toggleLike="toggleLike"}}', - setup, - test(assert) { - assert.ok(!!this.$('.actions button.like').length); - assert.ok(!!this.$('.actions button.like-count').length); - - click('.actions button.like'); - andThen(() => { - assert.ok(!this.$('.actions button.like').length); - assert.ok(!!this.$('.actions button.has-like').length); - }); - - click('.actions button.has-like'); - andThen(() => { - assert.ok(!!this.$('.actions button.like').length); - assert.ok(!this.$('.actions button.has-like').length); - }); - } -}); diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index 13fdc144e..48f0bcdbd 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -6,6 +6,7 @@ moduleFor('controller:topic', 'controller:topic', { }); import Topic from 'discourse/models/topic'; +import AppEvents from 'discourse/lib/app-events'; var buildTopic = function() { return Topic.create({ @@ -62,7 +63,7 @@ test("toggledSelectedPost", function() { }); test("selectAll", function() { - var tc = this.subject({model: buildTopic()}), + var tc = this.subject({model: buildTopic(), appEvents: AppEvents.create()}), post = Discourse.Post.create({id: 123, post_number: 2}), postStream = tc.get('model.postStream'); diff --git a/test/javascripts/fixtures/site-fixtures.js.es6 b/test/javascripts/fixtures/site-fixtures.js.es6 index 9e29ee832..81ce4b869 100644 --- a/test/javascripts/fixtures/site-fixtures.js.es6 +++ b/test/javascripts/fixtures/site-fixtures.js.es6 @@ -19,7 +19,8 @@ export default { "post_types":{ "regular":1, "moderator_action":2, - "small_action":3 + "small_action":3, + "whisper":4 }, "group_names":[ "admins", diff --git a/test/javascripts/helpers/component-test.js.es6 b/test/javascripts/helpers/component-test.js.es6 index 3ff28d3c9..cda11724b 100644 --- a/test/javascripts/helpers/component-test.js.es6 +++ b/test/javascripts/helpers/component-test.js.es6 @@ -6,21 +6,27 @@ export default function(name, opts) { opts = opts || {}; test(name, function(assert) { - if (opts.setup) { - const store = createStore(); - opts.setup.call(this, store); - } const appEvents = AppEvents.create(); - - loadAllHelpers(); - this.container.register('site-settings:main', Discourse.SiteSettings, { instantiate: false }); this.container.register('app-events:main', appEvents, { instantiate: false }); this.container.register('capabilities:main', Ember.Object); + this.container.register('site:main', Discourse.Site.current(), { instantiate: false }); this.container.injection('component', 'siteSettings', 'site-settings:main'); this.container.injection('component', 'appEvents', 'app-events:main'); this.container.injection('component', 'capabilities', 'capabilities:main'); + this.siteSettings = Discourse.SiteSettings; + + loadAllHelpers(); + + if (opts.setup) { + const store = createStore(); + this.currentUser = Discourse.User.create(); + this.container.register('store:main', store, { instantiate: false }); + this.container.register('current-user:main', this.currentUser, { instantiate: false }); + opts.setup.call(this, store); + } + andThen(() => this.render(opts.template)); andThen(() => opts.test.call(this, assert)); }); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index fca63e935..0aaad1ff9 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -85,7 +85,23 @@ export default function() { this.get('/users/:username/staff-info.json', () => response({})); - this.put('/categories/:category_id', function(request) { + this.get('/post_action_users', () => { + return response({ + post_action_users: [ + {id: 1, username: 'eviltrout', avatar_template: '/user_avatar/default/eviltrout/{size}/1.png', username_lower: 'eviltrout' } + ] + }); + }); + + this.get('/post_replies', () => { + return response({ post_replies: [{ id: 1234, cooked: 'wat' }] }); + }); + + this.get('/post_reply_histories', () => { + return response({ post_reply_histories: [{ id: 1234, cooked: 'wat' }] }); + }); + + this.put('/categories/:category_id', request => { const category = parsePostData(request.requestBody); return response({category}); }); @@ -132,6 +148,7 @@ export default function() { this.delete('/posts/:post_id', success); this.put('/posts/:post_id/recover', success); + this.get('/posts/:post_id/expand-embed', success); this.put('/posts/:post_id', request => { const data = parsePostData(request.requestBody); diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index 44812f7ec..c69058566 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -41,7 +41,6 @@ function acceptance(name, options) { Discourse.Utilities.avatarImg = () => ""; // For now don't do scrolling stuff in Test Mode - Ember.CloakedCollectionView.scrolled = Ember.K; HeaderView.reopen({examineDockHeader: Ember.K}); var siteJson = siteFixtures['site.json'].site; diff --git a/test/javascripts/helpers/widget-test.js.es6 b/test/javascripts/helpers/widget-test.js.es6 new file mode 100644 index 000000000..d30170ff1 --- /dev/null +++ b/test/javascripts/helpers/widget-test.js.es6 @@ -0,0 +1,9 @@ +import componentTest from 'helpers/component-test'; + +export function moduleForWidget(name) { + moduleForComponent(name, `widget:${name}`, { integration: true }); +} + +export function widgetTest(name, opts) { + return componentTest(name, opts); +} diff --git a/test/javascripts/models/post-stream-test.js.es6 b/test/javascripts/models/post-stream-test.js.es6 index 41098560a..0b9a7d3df 100644 --- a/test/javascripts/models/post-stream-test.js.es6 +++ b/test/javascripts/models/post-stream-test.js.es6 @@ -27,40 +27,6 @@ test('defaults', function() { present(postStream.get('topic')); }); -test('daysSincePrevious when appending', function(assert) { - const postStream = buildStream(10000001, [1,2,3]); - const store = postStream.store; - - const p1 = store.createRecord('post', {id: 1, post_number: 1, created_at: "2015-05-29T18:17:35.868Z"}), - p2 = store.createRecord('post', {id: 2, post_number: 2, created_at: "2015-06-01T01:07:25.761Z"}), - p3 = store.createRecord('post', {id: 3, post_number: 3, created_at: "2015-06-02T01:07:25.761Z"}); - - postStream.appendPost(p1); - postStream.appendPost(p2); - postStream.appendPost(p3); - - assert.ok(!p1.get('daysSincePrevious')); - assert.equal(p2.get('daysSincePrevious'), 2); - assert.equal(p3.get('daysSincePrevious'), 1); -}); - -test('daysSincePrevious when prepending', function(assert) { - const postStream = buildStream(10000001, [1,2,3]); - const store = postStream.store; - - const p1 = store.createRecord('post', {id: 1, post_number: 1, created_at: "2015-05-29T18:17:35.868Z"}), - p2 = store.createRecord('post', {id: 2, post_number: 2, created_at: "2015-06-01T01:07:25.761Z"}), - p3 = store.createRecord('post', {id: 3, post_number: 3, created_at: "2015-06-02T01:07:25.761Z"}); - - postStream.prependPost(p3); - postStream.prependPost(p2); - postStream.prependPost(p1); - - assert.ok(!p1.get('daysSincePrevious')); - assert.equal(p2.get('daysSincePrevious'), 2); - assert.equal(p3.get('daysSincePrevious'), 1); -}); - test('appending posts', function() { const postStream = buildStream(4567, [1, 3, 4]); const store = postStream.store; @@ -320,17 +286,6 @@ test("loadIntoIdentityMap with post ids", function() { }); }); -test("loading a post's history", function() { - const postStream = buildStream(1234); - const store = postStream.store; - const post = store.createRecord('post', {id: 4321}); - - postStream.findReplyHistory(post).then(function() { - present(postStream.findLoadedPost(2222), "it stores the returned post in the identity map"); - present(post.get('replyHistory'), "it sets the replyHistory attribute for the post"); - }); -}); - test("staging and undoing a new post", function() { const postStream = buildStream(10101, [1]); const store = postStream.store; diff --git a/test/javascripts/models/post-test.js.es6 b/test/javascripts/models/post-test.js.es6 index 773657750..1f881df12 100644 --- a/test/javascripts/models/post-test.js.es6 +++ b/test/javascripts/models/post-test.js.es6 @@ -14,7 +14,6 @@ test('defaults', function() { var post = Discourse.Post.create({id: 1}); blank(post.get('deleted_at'), "it has no deleted_at by default"); blank(post.get('deleted_by'), "there is no deleted_by by default"); - equal(post.get('replyHistory.length'), 0, "there is no reply history by default"); }); test('new_user', function() { @@ -47,16 +46,6 @@ test('updateFromPost', function() { equal(post.get('raw'), "different raw", "raw field updated"); }); -test('hasHistory', function() { - var post = Discourse.Post.create({id: 1}); - ok(!post.get('hasHistory'), 'posts without versions have no history'); - post.set('version', 1); - ok(!post.get('hasHistory'), 'posts with one version have no history'); - post.set('version', 2); - ok(post.get('hasHistory'), 'posts with more than one version have a history'); -}); - - test('destroy by staff', function() { var user = Discourse.User.create({username: 'staff', staff: true}), post = buildPost({user: user}); diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index 08f9256f6..d8ca4fe86 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -111,9 +111,6 @@ QUnit.testStart(function(ctx) { } }); -// Don't cloak in testing -Ember.CloakedCollectionView = Ember.CollectionView; - QUnit.testDone(function() { Ember.run.debounce = origDebounce; window.sandbox.restore(); diff --git a/test/javascripts/widgets/actions-summary-test.js.es6 b/test/javascripts/widgets/actions-summary-test.js.es6 new file mode 100644 index 000000000..17cb172c6 --- /dev/null +++ b/test/javascripts/widgets/actions-summary-test.js.es6 @@ -0,0 +1,80 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; + +moduleForWidget('actions-summary'); + +widgetTest('listing actions', { + template: '{{mount-widget widget="actions-summary" args=args}}', + setup() { + this.set('args', { + actionsSummary: [ + {action: 'off_topic', description: 'very off topic'}, + {action: 'spam', description: 'suspicious message'} + ] + }); + }, + test(assert) { + assert.equal(this.$('.post-actions .post-action').length, 2); + + click('.post-action:eq(0) .action-link a'); + andThen(() => { + assert.equal(this.$('.post-action:eq(0) img.avatar').length, 1, 'clicking it shows the user'); + }); + } +}); + +widgetTest('undo', { + template: '{{mount-widget widget="actions-summary" args=args undoPostAction="undoPostAction"}}', + setup() { + this.set('args', { + actionsSummary: [ + {action: 'off_topic', description: 'very off topic', canUndo: true}, + ] + }); + + this.on('undoPostAction', () => this.undid = true); + }, + test(assert) { + assert.equal(this.$('.post-actions .post-action').length, 1); + + click('.action-link.undo'); + andThen(() => { + assert.ok(this.undid, 'it triggered the action'); + }); + } +}); + +widgetTest('deferFlags', { + template: '{{mount-widget widget="actions-summary" args=args deferPostActionFlags="deferPostActionFlags"}}', + setup() { + this.set('args', { + actionsSummary: [ + {action: 'off_topic', description: 'very off topic', canDeferFlags: true, count: 1}, + ] + }); + + this.on('deferPostActionFlags', () => this.deferred = true); + }, + test(assert) { + assert.equal(this.$('.post-actions .post-action').length, 1); + + click('.action-link.defer-flags'); + andThen(() => { + assert.ok(this.deferred, 'it triggered the action'); + }); + } +}); + +widgetTest('post deleted', { + template: '{{mount-widget widget="actions-summary" args=args}}', + setup() { + this.set('args', { + isDeleted: true, + deletedByUsername: 'eviltrout', + deletedByAvatarTemplate: '/images/avatar.png' + }); + }, + test(assert) { + assert.ok(this.$('.post-action .fa-trash-o').length === 1, 'it has the deleted icon'); + assert.ok(this.$('.avatar[title=eviltrout]').length === 1, 'it has the deleted by avatar'); + } +}); diff --git a/test/javascripts/widgets/post-gutter-test.js.es6 b/test/javascripts/widgets/post-gutter-test.js.es6 new file mode 100644 index 000000000..55c4152cb --- /dev/null +++ b/test/javascripts/widgets/post-gutter-test.js.es6 @@ -0,0 +1,54 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; + +moduleForWidget('post-gutter'); + +widgetTest("duplicate links", { + template: '{{mount-widget widget="post-gutter" args=args}}', + setup() { + this.set('args', { + links: [ + { title: "Evil Trout Link", url: "http://eviltrout.com" }, + { title: "Evil Trout Link", url: "http://dupe.eviltrout.com" } + ] + }); + }, + test(assert) { + assert.equal(this.$('.post-links a.track-link').length, 1, 'it hides the dupe link'); + } +}); + +widgetTest("collapsed links", { + template: '{{mount-widget widget="post-gutter" args=args}}', + setup() { + this.set('args', { + links: [ + { title: "Link 1", url: "http://eviltrout.com?1" }, + { title: "Link 2", url: "http://eviltrout.com?2" }, + { title: "Link 3", url: "http://eviltrout.com?3" }, + { title: "Link 4", url: "http://eviltrout.com?4" }, + { title: "Link 5", url: "http://eviltrout.com?5" }, + { title: "Link 6", url: "http://eviltrout.com?6" }, + { title: "Link 7", url: "http://eviltrout.com?7" }, + ] + }); + }, + test(assert) { + assert.equal(this.$('.post-links a.track-link').length, 5, 'collapses by default'); + click('a.toggle-more'); + andThen(() => { + assert.equal(this.$('.post-links a.track-link').length, 7); + }); + } +}); + +widgetTest("reply as new topic", { + template: '{{mount-widget widget="post-gutter" args=args newTopicAction="newTopicAction"}}', + setup() { + this.set('args', { canReplyAsNewTopic: true }); + this.on('newTopicAction', () => this.newTopicTriggered = true); + }, + test(assert) { + click('a.reply-new'); + andThen(() => assert.ok(this.newTopicTriggered)); + } +}); diff --git a/test/javascripts/widgets/post-stream-test.js.es6 b/test/javascripts/widgets/post-stream-test.js.es6 new file mode 100644 index 000000000..62966d7b0 --- /dev/null +++ b/test/javascripts/widgets/post-stream-test.js.es6 @@ -0,0 +1,66 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; +import Topic from 'discourse/models/topic'; +import Post from 'discourse/models/post'; + +moduleForWidget('post-stream'); + +function postStreamTest(name, attrs) { + widgetTest(name, { + template: `{{mount-widget widget="post-stream" args=(as-hash posts=posts)}}`, + setup() { + this.set('posts', attrs.posts.call(this)); + }, + test: attrs.test + }); +} + +postStreamTest('basics', { + posts() { + const site = this.container.lookup('site:main'); + const topic = Topic.create({ details: { created_by: { id: 123 } } }); + return [ + Post.create({ topic, id: 1, post_number: 1, user_id: 123, primary_group_name: 'trout', + avatar_template: '/images/avatar.png' }), + Post.create({ topic, id: 2, post_number: 2, post_type: site.get('post_types.moderator_action') }), + Post.create({ topic, id: 3, post_number: 3, hidden: true }), + Post.create({ topic, id: 4, post_number: 4, post_type: site.get('post_types.whisper') }), + Post.create({ topic, id: 5, post_number: 5, wiki: true, via_email: true }) + ]; + }, + + test(assert) { + assert.equal(this.$('.post-stream').length, 1); + assert.equal(this.$('.topic-post').length, 5, 'renders all posts'); + + // look for special class bindings + assert.equal(this.$('.topic-post:eq(0).topic-owner').length, 1, 'it applies the topic owner class'); + assert.equal(this.$('.topic-post:eq(0).group-trout').length, 1, 'it applies the primary group class'); + assert.equal(this.$('.topic-post:eq(0).regular').length, 1, 'it applies the regular class'); + assert.equal(this.$('.topic-post:eq(1).moderator').length, 1, 'it applies the moderator class'); + assert.equal(this.$('.topic-post:eq(2).post-hidden').length, 1, 'it applies the hidden class'); + assert.equal(this.$('.topic-post:eq(3).whisper').length, 1, 'it applies the whisper class'); + assert.equal(this.$('.topic-post:eq(4).wiki').length, 1, 'it applies the wiki class'); + + // it renders an article for the body with appropriate attributes + assert.equal(this.$('article#post_2').length, 1); + assert.equal(this.$('article[data-user-id=123]').length, 1); + assert.equal(this.$('article[data-post-id=3]').length, 1); + assert.equal(this.$('article#post_5.via-email').length, 1); + + assert.equal(this.$('article:eq(0) .main-avatar').length, 1, 'renders the main avatar'); + } +}); + +postStreamTest('deleted posts', { + posts() { + const topic = Topic.create({ details: { created_by: { id: 123 } } }); + return [ + Post.create({ topic, id: 1, post_number: 1, deleted_at: new Date().getTime() }), + ]; + }, + + test(assert) { + assert.equal(this.$('.topic-post.deleted').length, 1, 'it applies the deleted class'); + assert.equal(this.$('.deleted-user-avatar').length, 1, 'it has the trash avatar'); + } +}); diff --git a/test/javascripts/widgets/post-test.js.es6 b/test/javascripts/widgets/post-test.js.es6 new file mode 100644 index 000000000..5c853c0dd --- /dev/null +++ b/test/javascripts/widgets/post-test.js.es6 @@ -0,0 +1,784 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; + +moduleForWidget('post'); + +widgetTest('basic elements', { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { shareUrl: '/example', post_number: 1 }); + }, + test(assert) { + assert.ok(this.$('.names').length, 'includes poster name'); + + assert.ok(this.$('a.post-date').length, 'includes post date'); + assert.ok(this.$('a.post-date[data-share-url]').length); + assert.ok(this.$('a.post-date[data-post-number]').length); + } +}); + +widgetTest('wiki', { + template: '{{mount-widget widget="post" args=args editPost="editPost"}}', + setup() { + this.set('args', { wiki: true }); + this.on('editPost', () => this.editPostCalled = true); + }, + test(assert) { + click('.post-info.wiki'); + andThen(() => { + assert.ok(this.editPostCalled, 'clicking the wiki icon edits the post'); + }); + } +}); + +widgetTest('via-email', { + template: '{{mount-widget widget="post" args=args showRawEmail="showRawEmail"}}', + setup() { + this.set('args', { via_email: true, canViewRawEmail: true }); + this.on('showRawEmail', () => this.rawEmailShown = true); + }, + test(assert) { + click('.post-info.via-email'); + andThen(() => { + assert.ok(this.rawEmailShown, 'clicking the enveloppe shows the raw email'); + }); + } +}); + +widgetTest('via-email without permission', { + template: '{{mount-widget widget="post" args=args showRawEmail="showRawEmail"}}', + setup() { + this.set('args', { via_email: true, canViewRawEmail: false }); + this.on('showRawEmail', () => this.rawEmailShown = true); + }, + test(assert) { + click('.post-info.via-email'); + andThen(() => { + assert.ok(!this.rawEmailShown, `clicking the enveloppe doesn't show the raw email`); + }); + } +}); + +widgetTest('history', { + template: '{{mount-widget widget="post" args=args showHistory="showHistory"}}', + setup() { + this.set('args', { version: 3, canViewEditHistory: true }); + this.on('showHistory', () => this.historyShown = true); + }, + test(assert) { + click('.post-info.edits'); + andThen(() => { + assert.ok(this.historyShown, 'clicking the pencil shows the history'); + }); + } +}); + +widgetTest('history without view permission', { + template: '{{mount-widget widget="post" args=args showHistory="showHistory"}}', + setup() { + this.set('args', { version: 3, canViewEditHistory: false }); + this.on('showHistory', () => this.historyShown = true); + }, + test(assert) { + click('.post-info.edits'); + andThen(() => { + assert.ok(!this.historyShown, `clicking the pencil doesn't show the history`); + }); + } +}); + +widgetTest('whisper', { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { isWhisper: true }); + }, + test(assert) { + assert.ok(this.$('.topic-post.whisper').length === 1); + assert.ok(this.$('.post-info.whisper').length === 1); + } +}); + +widgetTest('like count button', { + template: '{{mount-widget widget="post" model=post args=args}}', + setup(store) { + const topic = store.createRecord('topic', {id: 123}); + const post = store.createRecord('post', { + id: 1, + post_number: 1, + topic, + like_count: 3, + actions_summary: [ {id: 2, count: 1, hidden: false, can_act: true} ] + }); + this.set('post', post); + this.set('args', { likeCount: 1 }); + }, + test(assert) { + assert.ok(this.$('button.like-count').length === 1); + assert.ok(this.$('.who-liked').length === 0); + + // toggle it on + click('button.like-count'); + andThen(() => { + assert.ok(this.$('.who-liked').length === 1); + assert.ok(this.$('.who-liked a.trigger-user-card').length === 1); + }); + + // toggle it off + click('button.like-count'); + andThen(() => { + assert.ok(this.$('.who-liked').length === 0); + assert.ok(this.$('.who-liked a.trigger-user-card').length === 0); + }); + } +}); + +widgetTest(`like count with no likes`, { + template: '{{mount-widget widget="post" model=post args=args}}', + setup() { + this.set('args', { likeCount: 0 }); + }, + test(assert) { + assert.ok(this.$('button.like-count').length === 0); + } +}); + +widgetTest('share button', { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { shareUrl: 'http://share-me.example.com' }); + }, + test(assert) { + assert.ok(!!this.$('.actions button[data-share-url]').length, 'it renders a share button'); + } +}); + +widgetTest('liking', { + template: '{{mount-widget widget="post-menu" args=args toggleLike="toggleLike"}}', + setup() { + const args = { showLike: true, canToggleLike: true }; + this.set('args', args); + this.on('toggleLike', () => { + args.liked = !args.liked; + args.likeCount = args.liked ? 1 : 0; + }); + }, + test(assert) { + assert.ok(!!this.$('.actions button.like').length); + assert.ok(this.$('.actions button.like-count').length === 0); + + click('.actions button.like'); + andThen(() => { + assert.ok(!this.$('.actions button.like').length); + assert.ok(!!this.$('.actions button.has-like').length); + assert.ok(this.$('.actions button.like-count').length === 1); + }); + + click('.actions button.has-like'); + andThen(() => { + assert.ok(!!this.$('.actions button.like').length); + assert.ok(!this.$('.actions button.has-like').length); + assert.ok(this.$('.actions button.like-count').length === 0); + }); + } +}); + +widgetTest('edit button', { + template: '{{mount-widget widget="post" args=args editPost="editPost"}}', + setup() { + this.set('args', { canEdit: true }); + this.on('editPost', () => this.editPostCalled = true); + }, + test(assert) { + click('button.edit'); + andThen(() => { + assert.ok(this.editPostCalled, 'it triggered the edit action'); + }); + } +}); + +widgetTest(`edit button - can't edit`, { + template: '{{mount-widget widget="post" args=args editPost="editPost"}}', + setup() { + this.set('args', { canEdit: false }); + }, + test(assert) { + assert.equal(this.$('button.edit').length, 0, `button is not displayed`); + } +}); + +widgetTest('recover button', { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canDelete: true }); + this.on('deletePost', () => this.deletePostCalled = true); + }, + test(assert) { + click('button.delete'); + andThen(() => { + assert.ok(this.deletePostCalled, 'it triggered the delete action'); + }); + } +}); + +widgetTest('delete topic button', { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canDeleteTopic: true }); + this.on('deletePost', () => this.deletePostCalled = true); + }, + test(assert) { + click('button.delete'); + andThen(() => { + assert.ok(this.deletePostCalled, 'it triggered the delete action'); + }); + } +}); + +widgetTest(`delete topic button - can't delete`, { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canDeleteTopic: false }); + }, + test(assert) { + assert.equal(this.$('button.delete').length, 0, `button is not displayed`); + } +}); + +widgetTest('recover topic button', { + template: '{{mount-widget widget="post" args=args recoverPost="recoverPost"}}', + setup() { + this.set('args', { canRecoverTopic: true }); + this.on('recoverPost', () => this.recovered = true); + }, + test(assert) { + click('button.recover'); + andThen(() => assert.ok(this.recovered)); + } +}); + +widgetTest(`recover topic button - can't recover`, { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canRecoverTopic: false }); + }, + test(assert) { + assert.equal(this.$('button.recover').length, 0, `button is not displayed`); + } +}); + +widgetTest('delete post button', { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canDelete: true }); + this.on('deletePost', () => this.deletePostCalled = true); + }, + test(assert) { + click('button.delete'); + andThen(() => { + assert.ok(this.deletePostCalled, 'it triggered the delete action'); + }); + } +}); + +widgetTest(`delete post button - can't delete`, { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canDelete: false }); + }, + test(assert) { + assert.equal(this.$('button.delete').length, 0, `button is not displayed`); + } +}); + +widgetTest('recover post button', { + template: '{{mount-widget widget="post" args=args recoverPost="recoverPost"}}', + setup() { + this.set('args', { canRecover: true }); + this.on('recoverPost', () => this.recovered = true); + }, + test(assert) { + click('button.recover'); + andThen(() => assert.ok(this.recovered)); + } +}); + +widgetTest(`recover post button - can't recover`, { + template: '{{mount-widget widget="post" args=args deletePost="deletePost"}}', + setup() { + this.set('args', { canRecover: false }); + }, + test(assert) { + assert.equal(this.$('button.recover').length, 0, `button is not displayed`); + } +}); + +widgetTest(`flagging`, { + template: '{{mount-widget widget="post" args=args showFlags="showFlags"}}', + setup() { + this.set('args', { canFlag: true }); + this.on('showFlags', () => this.flagsShown = true); + }, + test(assert) { + assert.ok(this.$('button.create-flag').length === 1); + + click('button.create-flag'); + andThen(() => { + assert.ok(this.flagsShown, 'it triggered the action'); + }); + } +}); + +widgetTest(`flagging: can't flag`, { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canFlag: false }); + }, + test(assert) { + assert.ok(this.$('button.create-flag').length === 0); + } +}); + +widgetTest(`read indicator`, { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { read: true }); + }, + test(assert) { + assert.ok(this.$('.read-state.read').length); + } +}); + +widgetTest(`unread indicator`, { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { read: false }); + }, + test(assert) { + assert.ok(this.$('.read-state').length); + } +}); + +widgetTest("reply directly above (supressed)", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + replyToUsername: 'eviltrout', + replyToAvatarTemplate: '/images/avatar.png', + replyDirectlyAbove: true + }); + }, + test(assert) { + assert.equal(this.$('a.reply-to-tab').length, 0, 'hides the tab'); + assert.equal(this.$('.avoid-tab').length, 0, "doesn't have the avoid tab class"); + } +}); + +widgetTest("reply a few posts above (supressed)", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + replyToUsername: 'eviltrout', + replyToAvatarTemplate: '/images/avatar.png', + replyDirectlyAbove: false + }); + }, + test(assert) { + assert.ok(this.$('a.reply-to-tab').length, 'shows the tab'); + assert.equal(this.$('.avoid-tab').length, 1, "has the avoid tab class"); + } +}); + +widgetTest("reply directly above", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + replyToUsername: 'eviltrout', + replyToAvatarTemplate: '/images/avatar.png', + replyDirectlyAbove: true + }); + this.siteSettings.suppress_reply_directly_above = false; + }, + test(assert) { + assert.equal(this.$('.avoid-tab').length, 1, "has the avoid tab class"); + click('a.reply-to-tab'); + andThen(() => { + assert.equal(this.$('section.embedded-posts.top .cooked').length, 1); + assert.equal(this.$('section.embedded-posts i.fa-arrow-up').length, 1); + }); + } +}); + +widgetTest("cooked content hidden", { + template: '{{mount-widget widget="post" args=args expandHidden="expandHidden"}}', + setup() { + this.set('args', { cooked_hidden: true }); + this.on('expandHidden', () => this.unhidden = true); + }, + test(assert) { + click('.topic-body .expand-hidden'); + andThen(() => { + assert.ok(this.unhidden, 'triggers the action'); + }); + } +}); + +widgetTest("expand first post", { + template: '{{mount-widget widget="post" model=post args=args}}', + setup(store) { + this.set('args', { expandablePost: true }); + this.set('post', store.createRecord('post', { id: 1234 })); + }, + test(assert) { + click('.topic-body .expand-post'); + andThen(() => { + assert.equal(this.$('.expand-post').length, 0, 'button is gone'); + }); + } +}); + +widgetTest("can't bookmark", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canBookmark: false }); + }, + test(assert) { + assert.equal(this.$('button.bookmark').length, 0); + assert.equal(this.$('button.bookmarked').length, 0); + } +}); + +widgetTest("bookmark", { + template: '{{mount-widget widget="post" args=args toggleBookmark="toggleBookmark"}}', + setup() { + const args = { canBookmark: true }; + + this.set('args', args); + this.on('toggleBookmark', () => args.bookmarked = true); + }, + test(assert) { + assert.equal(this.$('.post-menu-area .bookmark').length, 1); + assert.equal(this.$('button.bookmarked').length, 0); + + click('button.bookmark'); + andThen(() => { + assert.equal(this.$('button.bookmarked').length, 1); + }); + } +}); + +widgetTest("can't show admin menu when you can't manage", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canManage: false }); + }, + test(assert) { + assert.equal(this.$('.post-menu-area .show-post-admin-menu').length, 0); + } +}); + +widgetTest("show admin menu", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canManage: true }); + }, + test(assert) { + assert.equal(this.$('.post-admin-menu').length, 0); + click('.post-menu-area .show-post-admin-menu'); + andThen(() => { + assert.equal(this.$('.post-admin-menu').length, 1, 'it shows the popup'); + }); + click('.post-menu-area'); + andThen(() => { + assert.equal(this.$('.post-admin-menu').length, 0, 'clicking outside clears the popup'); + }); + } +}); + +widgetTest("toggle moderator post", { + template: '{{mount-widget widget="post" args=args togglePostType="togglePostType"}}', + setup() { + this.set('args', { canManage: true }); + this.on('togglePostType', () => this.toggled = true); + }, + test(assert) { + click('.post-menu-area .show-post-admin-menu'); + click('.post-admin-menu .toggle-post-type'); + andThen(() => { + assert.ok(this.toggled); + assert.equal(this.$('.post-admin-menu').length, 0, 'also hides the menu'); + }); + } +}); +widgetTest("toggle moderator post", { + template: '{{mount-widget widget="post" args=args togglePostType="togglePostType"}}', + setup() { + this.set('args', { canManage: true }); + this.on('togglePostType', () => this.toggled = true); + }, + test(assert) { + click('.post-menu-area .show-post-admin-menu'); + click('.post-admin-menu .toggle-post-type'); + andThen(() => { + assert.ok(this.toggled); + assert.equal(this.$('.post-admin-menu').length, 0, 'also hides the menu'); + }); + } +}); + +widgetTest("rebake post", { + template: '{{mount-widget widget="post" args=args rebakePost="rebakePost"}}', + setup() { + this.set('args', { canManage: true }); + this.on('rebakePost', () => this.baked = true); + }, + test(assert) { + click('.post-menu-area .show-post-admin-menu'); + click('.post-admin-menu .rebuild-html'); + andThen(() => { + assert.ok(this.baked); + assert.equal(this.$('.post-admin-menu').length, 0, 'also hides the menu'); + }); + } +}); + +widgetTest("unhide post", { + template: '{{mount-widget widget="post" args=args unhidePost="unhidePost"}}', + setup() { + this.set('args', { canManage: true, hidden: true }); + this.on('unhidePost', () => this.unhidden = true); + }, + test(assert) { + click('.post-menu-area .show-post-admin-menu'); + click('.post-admin-menu .unhide-post'); + andThen(() => { + assert.ok(this.unhidden); + assert.equal(this.$('.post-admin-menu').length, 0, 'also hides the menu'); + }); + } +}); + +widgetTest("change owner", { + template: '{{mount-widget widget="post" args=args changePostOwner="changePostOwner"}}', + setup() { + this.currentUser.admin = true; + this.set('args', { canManage: true }); + this.on('changePostOwner', () => this.owned = true); + }, + test(assert) { + click('.post-menu-area .show-post-admin-menu'); + click('.post-admin-menu .change-owner'); + andThen(() => { + assert.ok(this.owned); + assert.equal(this.$('.post-admin-menu').length, 0, 'also hides the menu'); + }); + } +}); + +widgetTest("reply", { + template: '{{mount-widget widget="post" args=args replyToPost="replyToPost"}}', + setup() { + this.set('args', { canCreatePost: true }); + this.on('replyToPost', () => this.replied = true); + }, + test(assert) { + click('.post-controls .create'); + andThen(() => { + assert.ok(this.replied); + }); + } +}); + +widgetTest("reply - without permissions", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { canCreatePost: false }); + }, + test(assert) { + assert.equal(this.$('.post-controls .create').length, 0); + } +}); + +widgetTest("replies - no replies", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', {replyCount: 0}); + }, + test(assert) { + assert.equal(this.$('button.show-replies').length, 0); + } +}); + +widgetTest("replies - multiple replies", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.siteSettings.suppress_reply_directly_below = true; + this.set('args', {replyCount: 2, replyDirectlyBelow: true}); + }, + test(assert) { + assert.equal(this.$('button.show-replies').length, 1); + } +}); + +widgetTest("replies - one below, suppressed", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.siteSettings.suppress_reply_directly_below = true; + this.set('args', {replyCount: 1, replyDirectlyBelow: true}); + }, + test(assert) { + assert.equal(this.$('button.show-replies').length, 0); + } +}); + +widgetTest("replies - one below, not suppressed", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.siteSettings.suppress_reply_directly_below = false; + this.set('args', {id: 6654, replyCount: 1, replyDirectlyBelow: true}); + }, + test(assert) { + click('button.show-replies'); + andThen(() => { + assert.equal(this.$('section.embedded-posts.bottom .cooked').length, 1); + assert.equal(this.$('section.embedded-posts i.fa-arrow-down').length, 1); + }); + } +}); + +widgetTest("topic map not shown", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { showTopicMap: false }); + }, + test(assert) { + assert.equal(this.$('.topic-map').length, 0); + } +}); + +widgetTest("topic map - few posts", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + showTopicMap: true, + topicPostsCount: 2, + participants: [ + {username: 'eviltrout'}, + {username: 'codinghorror'}, + ] + }); + }, + test(assert) { + assert.equal(this.$('li.avatars a.poster').length, 0, 'shows no participants when collapsed'); + + click('nav.buttons button'); + andThen(() => { + assert.equal(this.$('.topic-map-expanded a.poster').length, 2, 'shows all when expanded'); + }); + } +}); + +widgetTest("topic map - participants", { + template: '{{mount-widget widget="post" args=args toggleParticipant="toggleParticipant"}}', + setup() { + this.set('args', { + showTopicMap: true, + topicPostsCount: 10, + participants: [ + {username: 'eviltrout'}, + {username: 'codinghorror'}, + {username: 'sam'}, + {username: 'ZogStrIP'}, + ], + userFilters: ['sam', 'codinghorror'] + }); + + this.on('toggleParticipant', () => this.participantToggled = true); + }, + test(assert) { + assert.equal(this.$('li.avatars a.poster').length, 3, 'limits to three participants'); + + click('nav.buttons button'); + andThen(() => { + assert.equal(this.$('li.avatars a.poster').length, 0); + assert.equal(this.$('.topic-map-expanded a.poster').length, 4, 'shows all when expanded'); + assert.equal(this.$('a.poster.toggled').length, 2, 'two are toggled'); + }); + + click('.topic-map-expanded a.poster:eq(0)'); + andThen(() => assert.ok(this.participantToggled)); + } +}); + +widgetTest("topic map - links", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + showTopicMap: true, + topicLinks: [ + {url: 'http://link1.example.com', clicks: 0}, + {url: 'http://link2.example.com', clicks: 0}, + {url: 'http://link3.example.com', clicks: 0}, + {url: 'http://link4.example.com', clicks: 0}, + {url: 'http://link5.example.com', clicks: 0}, + {url: 'http://link6.example.com', clicks: 0}, + ] + }); + }, + test(assert) { + assert.equal(this.$('.topic-map').length, 1); + assert.equal(this.$('.map.map-collapsed').length, 1); + assert.equal(this.$('.topic-map-expanded').length, 0); + + click('nav.buttons button'); + andThen(() => { + assert.equal(this.$('.map.map-collapsed').length, 0); + assert.equal(this.$('.topic-map i.fa-chevron-up').length, 1); + assert.equal(this.$('.topic-map-expanded').length, 1); + assert.equal(this.$('.topic-map-expanded .topic-link').length, 5, 'it limits the links displayed'); + }); + + click('.link-summary a'); + andThen(() => { + assert.equal(this.$('.topic-map-expanded .topic-link').length, 6, 'all links now shown'); + }); + } +}); + +widgetTest("topic map - no summary", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { showTopicMap: true }); + }, + test(assert) { + assert.equal(this.$('.toggle-summary').length, 0); + } +}); + +widgetTest("topic map - has summary", { + template: '{{mount-widget widget="post" args=args toggleSummary="toggleSummary"}}', + setup() { + this.set('args', { showTopicMap: true, hasTopicSummary: true }); + this.on('toggleSummary', () => this.summaryToggled = true); + }, + test(assert) { + assert.equal(this.$('.toggle-summary').length, 1); + + click('.toggle-summary button'); + andThen(() => assert.ok(this.summaryToggled)); + } +}); + +widgetTest("pm map", { + template: '{{mount-widget widget="post" args=args}}', + setup() { + this.set('args', { + showTopicMap: true, + showPMMap: true, + allowedGroups: [], + allowedUsers: [ Ember.Object.create({ username: 'eviltrout' }) ] + }); + }, + test(assert) { + assert.equal(this.$('.private-message-map').length, 1); + assert.equal(this.$('.private-message-map .user').length, 1); + } +}); diff --git a/test/javascripts/widgets/poster-name-test.js.es6 b/test/javascripts/widgets/poster-name-test.js.es6 new file mode 100644 index 000000000..5e6782625 --- /dev/null +++ b/test/javascripts/widgets/poster-name-test.js.es6 @@ -0,0 +1,67 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; + +moduleForWidget('poster-name'); + +widgetTest('basic rendering', { + template: '{{mount-widget widget="poster-name" args=args}}', + setup() { + this.set('args', { + username: 'eviltrout', + usernameUrl: '/users/eviltrout', + name: 'Robin Ward', + user_title: 'Trout Master' }); + }, + test(assert) { + assert.ok(this.$('.names').length); + assert.ok(this.$('span.username').length); + assert.ok(this.$('a[data-auto-route=true]').length); + assert.ok(this.$('a[data-user-card=eviltrout]').length); + assert.equal(this.$('.username a').text(), 'eviltrout'); + assert.equal(this.$('.full-name a').text(), 'Robin Ward'); + assert.equal(this.$('.user-title').text(), 'Trout Master'); + } +}); + +widgetTest('extra classes and glyphs', { + template: '{{mount-widget widget="poster-name" args=args}}', + setup() { + this.set('args', { + username: 'eviltrout', + usernameUrl: '/users/eviltrout', + staff: true, + admin: true, + moderator: true, + new_user: true, + primary_group_name: 'fish' + }); + }, + test(assert) { + assert.ok(this.$('span.staff').length); + assert.ok(this.$('span.admin').length); + assert.ok(this.$('span.moderator').length); + assert.ok(this.$('i.fa-shield').length); + assert.ok(this.$('span.new-user').length); + assert.ok(this.$('span.fish').length); + } +}); + +widgetTest('disable display name on posts', { + template: '{{mount-widget widget="poster-name" args=args}}', + setup() { + this.siteSettings.display_name_on_posts = false; + this.set('args', { username: 'eviltrout', name: 'Robin Ward' }); + }, + test(assert) { + assert.equal(this.$('.full-name').length, 0); + } +}); + +widgetTest("doesn't render a name if it's similar to the username", { + template: '{{mount-widget widget="poster-name" args=args}}', + setup() { + this.set('args', { username: 'eviltrout', name: 'evil-trout' }); + }, + test(assert) { + assert.equal(this.$('.full-name').length, 0); + } +}); diff --git a/vendor/assets/javascripts/virtual-dom-amd.js b/vendor/assets/javascripts/virtual-dom-amd.js new file mode 100644 index 000000000..75ee9dccf --- /dev/null +++ b/vendor/assets/javascripts/virtual-dom-amd.js @@ -0,0 +1,4 @@ +// Just a wrapper from the standalone browserified virtual-dom +define("virtual-dom", [], function() { + return virtualDom; +}); diff --git a/vendor/assets/javascripts/virtual-dom.js b/vendor/assets/javascripts/virtual-dom.js new file mode 100644 index 000000000..ef7710be9 --- /dev/null +++ b/vendor/assets/javascripts/virtual-dom.js @@ -0,0 +1,1668 @@ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.virtualDom=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o + * Available under the MIT License + * ECMAScript compliant, uniform cross-browser split method + */ + +/** + * Splits a string into an array of strings using a regex or string separator. Matches of the + * separator are not included in the result array. However, if `separator` is a regex that contains + * capturing groups, backreferences are spliced into the result each time `separator` is matched. + * Fixes browser bugs compared to the native `String.prototype.split` and can be used reliably + * cross-browser. + * @param {String} str String to split. + * @param {RegExp|String} separator Regex or string to use for separating the string. + * @param {Number} [limit] Maximum number of items to include in the result array. + * @returns {Array} Array of substrings. + * @example + * + * // Basic use + * split('a b c d', ' '); + * // -> ['a', 'b', 'c', 'd'] + * + * // With limit + * split('a b c d', ' ', 2); + * // -> ['a', 'b'] + * + * // Backreferences in result array + * split('..word1 word2..', /([a-z]+)(\d+)/i); + * // -> ['..', 'word', '1', ' ', 'word', '2', '..'] + */ +module.exports = (function split(undef) { + + var nativeSplit = String.prototype.split, + compliantExecNpcg = /()??/.exec("")[1] === undef, + // NPCG: nonparticipating capturing group + self; + + self = function(str, separator, limit) { + // If `separator` is not a regex, use `nativeSplit` + if (Object.prototype.toString.call(separator) !== "[object RegExp]") { + return nativeSplit.call(str, separator, limit); + } + var output = [], + flags = (separator.ignoreCase ? "i" : "") + (separator.multiline ? "m" : "") + (separator.extended ? "x" : "") + // Proposed for ES6 + (separator.sticky ? "y" : ""), + // Firefox 3+ + lastLastIndex = 0, + // Make `global` and avoid `lastIndex` issues by working with a copy + separator = new RegExp(separator.source, flags + "g"), + separator2, match, lastIndex, lastLength; + str += ""; // Type-convert + if (!compliantExecNpcg) { + // Doesn't need flags gy, but they don't hurt + separator2 = new RegExp("^" + separator.source + "$(?!\\s)", flags); + } + /* Values for `limit`, per the spec: + * If undefined: 4294967295 // Math.pow(2, 32) - 1 + * If 0, Infinity, or NaN: 0 + * If positive number: limit = Math.floor(limit); if (limit > 4294967295) limit -= 4294967296; + * If negative number: 4294967296 - Math.floor(Math.abs(limit)) + * If other: Type-convert, then use the above rules + */ + limit = limit === undef ? -1 >>> 0 : // Math.pow(2, 32) - 1 + limit >>> 0; // ToUint32(limit) + while (match = separator.exec(str)) { + // `separator.lastIndex` is not reliable cross-browser + lastIndex = match.index + match[0].length; + if (lastIndex > lastLastIndex) { + output.push(str.slice(lastLastIndex, match.index)); + // Fix browsers whose `exec` methods don't consistently return `undefined` for + // nonparticipating capturing groups + if (!compliantExecNpcg && match.length > 1) { + match[0].replace(separator2, function() { + for (var i = 1; i < arguments.length - 2; i++) { + if (arguments[i] === undef) { + match[i] = undef; + } + } + }); + } + if (match.length > 1 && match.index < str.length) { + Array.prototype.push.apply(output, match.slice(1)); + } + lastLength = match[0].length; + lastLastIndex = lastIndex; + if (output.length >= limit) { + break; + } + } + if (separator.lastIndex === match.index) { + separator.lastIndex++; // Avoid an infinite loop + } + } + if (lastLastIndex === str.length) { + if (lastLength || !separator.test("")) { + output.push(""); + } + } else { + output.push(str.slice(lastLastIndex)); + } + return output.length > limit ? output.slice(0, limit) : output; + }; + + return self; +})(); + +},{}],6:[function(require,module,exports){ + +},{}],7:[function(require,module,exports){ +'use strict'; + +var OneVersionConstraint = require('individual/one-version'); + +var MY_VERSION = '7'; +OneVersionConstraint('ev-store', MY_VERSION); + +var hashKey = '__EV_STORE_KEY@' + MY_VERSION; + +module.exports = EvStore; + +function EvStore(elem) { + var hash = elem[hashKey]; + + if (!hash) { + hash = elem[hashKey] = {}; + } + + return hash; +} + +},{"individual/one-version":9}],8:[function(require,module,exports){ +(function (global){ +'use strict'; + +/*global window, global*/ + +var root = typeof window !== 'undefined' ? + window : typeof global !== 'undefined' ? + global : {}; + +module.exports = Individual; + +function Individual(key, value) { + if (key in root) { + return root[key]; + } + + root[key] = value; + + return value; +} + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{}],9:[function(require,module,exports){ +'use strict'; + +var Individual = require('./index.js'); + +module.exports = OneVersion; + +function OneVersion(moduleName, version, defaultValue) { + var key = '__INDIVIDUAL_ONE_VERSION_' + moduleName; + var enforceKey = key + '_ENFORCE_SINGLETON'; + + var versionValue = Individual(enforceKey, version); + + if (versionValue !== version) { + throw new Error('Can only have one copy of ' + + moduleName + '.\n' + + 'You already have version ' + versionValue + + ' installed.\n' + + 'This means you cannot install version ' + version); + } + + return Individual(key, defaultValue); +} + +},{"./index.js":8}],10:[function(require,module,exports){ +(function (global){ +var topLevel = typeof global !== 'undefined' ? global : + typeof window !== 'undefined' ? window : {} +var minDoc = require('min-document'); + +if (typeof document !== 'undefined') { + module.exports = document; +} else { + var doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4']; + + if (!doccy) { + doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4'] = minDoc; + } + + module.exports = doccy; +} + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"min-document":6}],11:[function(require,module,exports){ +"use strict"; + +module.exports = function isObject(x) { + return typeof x === "object" && x !== null; +}; + +},{}],12:[function(require,module,exports){ +var nativeIsArray = Array.isArray +var toString = Object.prototype.toString + +module.exports = nativeIsArray || isArray + +function isArray(obj) { + return toString.call(obj) === "[object Array]" +} + +},{}],13:[function(require,module,exports){ +var patch = require("./vdom/patch.js") + +module.exports = patch + +},{"./vdom/patch.js":18}],14:[function(require,module,exports){ +var isObject = require("is-object") +var isHook = require("../vnode/is-vhook.js") + +module.exports = applyProperties + +function applyProperties(node, props, previous) { + for (var propName in props) { + var propValue = props[propName] + + if (propValue === undefined) { + removeProperty(node, propName, propValue, previous); + } else if (isHook(propValue)) { + removeProperty(node, propName, propValue, previous) + if (propValue.hook) { + propValue.hook(node, + propName, + previous ? previous[propName] : undefined) + } + } else { + if (isObject(propValue)) { + patchObject(node, props, previous, propName, propValue); + } else { + node[propName] = propValue + } + } + } +} + +function removeProperty(node, propName, propValue, previous) { + if (previous) { + var previousValue = previous[propName] + + if (!isHook(previousValue)) { + if (propName === "attributes") { + for (var attrName in previousValue) { + node.removeAttribute(attrName) + } + } else if (propName === "style") { + for (var i in previousValue) { + node.style[i] = "" + } + } else if (typeof previousValue === "string") { + node[propName] = "" + } else { + node[propName] = null + } + } else if (previousValue.unhook) { + previousValue.unhook(node, propName, propValue) + } + } +} + +function patchObject(node, props, previous, propName, propValue) { + var previousValue = previous ? previous[propName] : undefined + + // Set attributes + if (propName === "attributes") { + for (var attrName in propValue) { + var attrValue = propValue[attrName] + + if (attrValue === undefined) { + node.removeAttribute(attrName) + } else { + node.setAttribute(attrName, attrValue) + } + } + + return + } + + if(previousValue && isObject(previousValue) && + getPrototype(previousValue) !== getPrototype(propValue)) { + node[propName] = propValue + return + } + + if (!isObject(node[propName])) { + node[propName] = {} + } + + var replacer = propName === "style" ? "" : undefined + + for (var k in propValue) { + var value = propValue[k] + node[propName][k] = (value === undefined) ? replacer : value + } +} + +function getPrototype(value) { + if (Object.getPrototypeOf) { + return Object.getPrototypeOf(value) + } else if (value.__proto__) { + return value.__proto__ + } else if (value.constructor) { + return value.constructor.prototype + } +} + +},{"../vnode/is-vhook.js":26,"is-object":11}],15:[function(require,module,exports){ +var document = require("global/document") + +var applyProperties = require("./apply-properties") + +var isVNode = require("../vnode/is-vnode.js") +var isVText = require("../vnode/is-vtext.js") +var isWidget = require("../vnode/is-widget.js") +var handleThunk = require("../vnode/handle-thunk.js") + +module.exports = createElement + +function createElement(vnode, opts) { + var doc = opts ? opts.document || document : document + var warn = opts ? opts.warn : null + + vnode = handleThunk(vnode).a + + if (isWidget(vnode)) { + return vnode.init() + } else if (isVText(vnode)) { + return doc.createTextNode(vnode.text) + } else if (!isVNode(vnode)) { + if (warn) { + warn("Item is not a valid virtual dom node", vnode) + } + return null + } + + var node = (vnode.namespace === null) ? + doc.createElement(vnode.tagName) : + doc.createElementNS(vnode.namespace, vnode.tagName) + + var props = vnode.properties + applyProperties(node, props) + + var children = vnode.children + + for (var i = 0; i < children.length; i++) { + var childNode = createElement(children[i], opts) + if (childNode) { + node.appendChild(childNode) + } + } + + return node +} + +},{"../vnode/handle-thunk.js":24,"../vnode/is-vnode.js":27,"../vnode/is-vtext.js":28,"../vnode/is-widget.js":29,"./apply-properties":14,"global/document":10}],16:[function(require,module,exports){ +// Maps a virtual DOM tree onto a real DOM tree in an efficient manner. +// We don't want to read all of the DOM nodes in the tree so we use +// the in-order tree indexing to eliminate recursion down certain branches. +// We only recurse into a DOM node if we know that it contains a child of +// interest. + +var noChild = {} + +module.exports = domIndex + +function domIndex(rootNode, tree, indices, nodes) { + if (!indices || indices.length === 0) { + return {} + } else { + indices.sort(ascending) + return recurse(rootNode, tree, indices, nodes, 0) + } +} + +function recurse(rootNode, tree, indices, nodes, rootIndex) { + nodes = nodes || {} + + + if (rootNode) { + if (indexInRange(indices, rootIndex, rootIndex)) { + nodes[rootIndex] = rootNode + } + + var vChildren = tree.children + + if (vChildren) { + + var childNodes = rootNode.childNodes + + for (var i = 0; i < tree.children.length; i++) { + rootIndex += 1 + + var vChild = vChildren[i] || noChild + var nextIndex = rootIndex + (vChild.count || 0) + + // skip recursion down the tree if there are no nodes down here + if (indexInRange(indices, rootIndex, nextIndex)) { + recurse(childNodes[i], vChild, indices, nodes, rootIndex) + } + + rootIndex = nextIndex + } + } + } + + return nodes +} + +// Binary search for an index in the interval [left, right] +function indexInRange(indices, left, right) { + if (indices.length === 0) { + return false + } + + var minIndex = 0 + var maxIndex = indices.length - 1 + var currentIndex + var currentItem + + while (minIndex <= maxIndex) { + currentIndex = ((maxIndex + minIndex) / 2) >> 0 + currentItem = indices[currentIndex] + + if (minIndex === maxIndex) { + return currentItem >= left && currentItem <= right + } else if (currentItem < left) { + minIndex = currentIndex + 1 + } else if (currentItem > right) { + maxIndex = currentIndex - 1 + } else { + return true + } + } + + return false; +} + +function ascending(a, b) { + return a > b ? 1 : -1 +} + +},{}],17:[function(require,module,exports){ +var applyProperties = require("./apply-properties") + +var isWidget = require("../vnode/is-widget.js") +var VPatch = require("../vnode/vpatch.js") + +var updateWidget = require("./update-widget") + +module.exports = applyPatch + +function applyPatch(vpatch, domNode, renderOptions) { + var type = vpatch.type + var vNode = vpatch.vNode + var patch = vpatch.patch + + switch (type) { + case VPatch.REMOVE: + return removeNode(domNode, vNode) + case VPatch.INSERT: + return insertNode(domNode, patch, renderOptions) + case VPatch.VTEXT: + return stringPatch(domNode, vNode, patch, renderOptions) + case VPatch.WIDGET: + return widgetPatch(domNode, vNode, patch, renderOptions) + case VPatch.VNODE: + return vNodePatch(domNode, vNode, patch, renderOptions) + case VPatch.ORDER: + reorderChildren(domNode, patch) + return domNode + case VPatch.PROPS: + applyProperties(domNode, patch, vNode.properties) + return domNode + case VPatch.THUNK: + return replaceRoot(domNode, + renderOptions.patch(domNode, patch, renderOptions)) + default: + return domNode + } +} + +function removeNode(domNode, vNode) { + var parentNode = domNode.parentNode + + if (parentNode) { + parentNode.removeChild(domNode) + } + + destroyWidget(domNode, vNode); + + return null +} + +function insertNode(parentNode, vNode, renderOptions) { + var newNode = renderOptions.render(vNode, renderOptions) + + if (parentNode) { + parentNode.appendChild(newNode) + } + + return parentNode +} + +function stringPatch(domNode, leftVNode, vText, renderOptions) { + var newNode + + if (domNode.nodeType === 3) { + domNode.replaceData(0, domNode.length, vText.text) + newNode = domNode + } else { + var parentNode = domNode.parentNode + newNode = renderOptions.render(vText, renderOptions) + + if (parentNode && newNode !== domNode) { + parentNode.replaceChild(newNode, domNode) + } + } + + return newNode +} + +function widgetPatch(domNode, leftVNode, widget, renderOptions) { + var updating = updateWidget(leftVNode, widget) + var newNode + + if (updating) { + newNode = widget.update(leftVNode, domNode) || domNode + } else { + newNode = renderOptions.render(widget, renderOptions) + } + + var parentNode = domNode.parentNode + + if (parentNode && newNode !== domNode) { + parentNode.replaceChild(newNode, domNode) + } + + if (!updating) { + destroyWidget(domNode, leftVNode) + } + + return newNode +} + +function vNodePatch(domNode, leftVNode, vNode, renderOptions) { + var parentNode = domNode.parentNode + var newNode = renderOptions.render(vNode, renderOptions) + + if (parentNode && newNode !== domNode) { + parentNode.replaceChild(newNode, domNode) + } + + return newNode +} + +function destroyWidget(domNode, w) { + if (typeof w.destroy === "function" && isWidget(w)) { + w.destroy(domNode) + } +} + +function reorderChildren(domNode, moves) { + var childNodes = domNode.childNodes + var keyMap = {} + var node + var remove + var insert + + for (var i = 0; i < moves.removes.length; i++) { + remove = moves.removes[i] + node = childNodes[remove.from] + if (remove.key) { + keyMap[remove.key] = node + } + domNode.removeChild(node) + } + + var length = childNodes.length + for (var j = 0; j < moves.inserts.length; j++) { + insert = moves.inserts[j] + node = keyMap[insert.key] + // this is the weirdest bug i've ever seen in webkit + domNode.insertBefore(node, insert.to >= length++ ? null : childNodes[insert.to]) + } +} + +function replaceRoot(oldRoot, newRoot) { + if (oldRoot && newRoot && oldRoot !== newRoot && oldRoot.parentNode) { + oldRoot.parentNode.replaceChild(newRoot, oldRoot) + } + + return newRoot; +} + +},{"../vnode/is-widget.js":29,"../vnode/vpatch.js":32,"./apply-properties":14,"./update-widget":19}],18:[function(require,module,exports){ +var document = require("global/document") +var isArray = require("x-is-array") + +var render = require("./create-element") +var domIndex = require("./dom-index") +var patchOp = require("./patch-op") +module.exports = patch + +function patch(rootNode, patches, renderOptions) { + renderOptions = renderOptions || {} + renderOptions.patch = renderOptions.patch && renderOptions.patch !== patch + ? renderOptions.patch + : patchRecursive + renderOptions.render = renderOptions.render || render + + return renderOptions.patch(rootNode, patches, renderOptions) +} + +function patchRecursive(rootNode, patches, renderOptions) { + var indices = patchIndices(patches) + + if (indices.length === 0) { + return rootNode + } + + var index = domIndex(rootNode, patches.a, indices) + var ownerDocument = rootNode.ownerDocument + + if (!renderOptions.document && ownerDocument !== document) { + renderOptions.document = ownerDocument + } + + for (var i = 0; i < indices.length; i++) { + var nodeIndex = indices[i] + rootNode = applyPatch(rootNode, + index[nodeIndex], + patches[nodeIndex], + renderOptions) + } + + return rootNode +} + +function applyPatch(rootNode, domNode, patchList, renderOptions) { + if (!domNode) { + return rootNode + } + + var newNode + + if (isArray(patchList)) { + for (var i = 0; i < patchList.length; i++) { + newNode = patchOp(patchList[i], domNode, renderOptions) + + if (domNode === rootNode) { + rootNode = newNode + } + } + } else { + newNode = patchOp(patchList, domNode, renderOptions) + + if (domNode === rootNode) { + rootNode = newNode + } + } + + return rootNode +} + +function patchIndices(patches) { + var indices = [] + + for (var key in patches) { + if (key !== "a") { + indices.push(Number(key)) + } + } + + return indices +} + +},{"./create-element":15,"./dom-index":16,"./patch-op":17,"global/document":10,"x-is-array":12}],19:[function(require,module,exports){ +var isWidget = require("../vnode/is-widget.js") + +module.exports = updateWidget + +function updateWidget(a, b) { + if (isWidget(a) && isWidget(b)) { + if ("name" in a && "name" in b) { + return a.id === b.id + } else { + return a.init === b.init + } + } + + return false +} + +},{"../vnode/is-widget.js":29}],20:[function(require,module,exports){ +'use strict'; + +var EvStore = require('ev-store'); + +module.exports = EvHook; + +function EvHook(value) { + if (!(this instanceof EvHook)) { + return new EvHook(value); + } + + this.value = value; +} + +EvHook.prototype.hook = function (node, propertyName) { + var es = EvStore(node); + var propName = propertyName.substr(3); + + es[propName] = this.value; +}; + +EvHook.prototype.unhook = function(node, propertyName) { + var es = EvStore(node); + var propName = propertyName.substr(3); + + es[propName] = undefined; +}; + +},{"ev-store":7}],21:[function(require,module,exports){ +'use strict'; + +module.exports = SoftSetHook; + +function SoftSetHook(value) { + if (!(this instanceof SoftSetHook)) { + return new SoftSetHook(value); + } + + this.value = value; +} + +SoftSetHook.prototype.hook = function (node, propertyName) { + if (node[propertyName] !== this.value) { + node[propertyName] = this.value; + } +}; + +},{}],22:[function(require,module,exports){ +'use strict'; + +var isArray = require('x-is-array'); + +var VNode = require('../vnode/vnode.js'); +var VText = require('../vnode/vtext.js'); +var isVNode = require('../vnode/is-vnode'); +var isVText = require('../vnode/is-vtext'); +var isWidget = require('../vnode/is-widget'); +var isHook = require('../vnode/is-vhook'); +var isVThunk = require('../vnode/is-thunk'); + +var parseTag = require('./parse-tag.js'); +var softSetHook = require('./hooks/soft-set-hook.js'); +var evHook = require('./hooks/ev-hook.js'); + +module.exports = h; + +function h(tagName, properties, children) { + var childNodes = []; + var tag, props, key, namespace; + + if (!children && isChildren(properties)) { + children = properties; + props = {}; + } + + props = props || properties || {}; + tag = parseTag(tagName, props); + + // support keys + if (props.hasOwnProperty('key')) { + key = props.key; + props.key = undefined; + } + + // support namespace + if (props.hasOwnProperty('namespace')) { + namespace = props.namespace; + props.namespace = undefined; + } + + // fix cursor bug + if (tag === 'INPUT' && + !namespace && + props.hasOwnProperty('value') && + props.value !== undefined && + !isHook(props.value) + ) { + props.value = softSetHook(props.value); + } + + transformProperties(props); + + if (children !== undefined && children !== null) { + addChild(children, childNodes, tag, props); + } + + + return new VNode(tag, props, childNodes, key, namespace); +} + +function addChild(c, childNodes, tag, props) { + if (typeof c === 'string') { + childNodes.push(new VText(c)); + } else if (typeof c === 'number') { + childNodes.push(new VText(String(c))); + } else if (isChild(c)) { + childNodes.push(c); + } else if (isArray(c)) { + for (var i = 0; i < c.length; i++) { + addChild(c[i], childNodes, tag, props); + } + } else if (c === null || c === undefined) { + return; + } else { + throw UnexpectedVirtualElement({ + foreignObject: c, + parentVnode: { + tagName: tag, + properties: props + } + }); + } +} + +function transformProperties(props) { + for (var propName in props) { + if (props.hasOwnProperty(propName)) { + var value = props[propName]; + + if (isHook(value)) { + continue; + } + + if (propName.substr(0, 3) === 'ev-') { + // add ev-foo support + props[propName] = evHook(value); + } + } + } +} + +function isChild(x) { + return isVNode(x) || isVText(x) || isWidget(x) || isVThunk(x); +} + +function isChildren(x) { + return typeof x === 'string' || isArray(x) || isChild(x); +} + +function UnexpectedVirtualElement(data) { + var err = new Error(); + + err.type = 'virtual-hyperscript.unexpected.virtual-element'; + err.message = 'Unexpected virtual child passed to h().\n' + + 'Expected a VNode / Vthunk / VWidget / string but:\n' + + 'got:\n' + + errorString(data.foreignObject) + + '.\n' + + 'The parent vnode is:\n' + + errorString(data.parentVnode) + '\n' + + 'Suggested fix: change your `h(..., [ ... ])` callsite.'; + err.foreignObject = data.foreignObject; + err.parentVnode = data.parentVnode; + + return err; +} + +function errorString(obj) { + try { + return JSON.stringify(obj, null, ' '); + } catch (e) { + return String(obj); + } +} + +},{"../vnode/is-thunk":25,"../vnode/is-vhook":26,"../vnode/is-vnode":27,"../vnode/is-vtext":28,"../vnode/is-widget":29,"../vnode/vnode.js":31,"../vnode/vtext.js":33,"./hooks/ev-hook.js":20,"./hooks/soft-set-hook.js":21,"./parse-tag.js":23,"x-is-array":12}],23:[function(require,module,exports){ +'use strict'; + +var split = require('browser-split'); + +var classIdSplit = /([\.#]?[a-zA-Z0-9\u007F-\uFFFF_:-]+)/; +var notClassId = /^\.|#/; + +module.exports = parseTag; + +function parseTag(tag, props) { + if (!tag) { + return 'DIV'; + } + + var noId = !(props.hasOwnProperty('id')); + + var tagParts = split(tag, classIdSplit); + var tagName = null; + + if (notClassId.test(tagParts[1])) { + tagName = 'DIV'; + } + + var classes, part, type, i; + + for (i = 0; i < tagParts.length; i++) { + part = tagParts[i]; + + if (!part) { + continue; + } + + type = part.charAt(0); + + if (!tagName) { + tagName = part; + } else if (type === '.') { + classes = classes || []; + classes.push(part.substring(1, part.length)); + } else if (type === '#' && noId) { + props.id = part.substring(1, part.length); + } + } + + if (classes) { + if (props.className) { + classes.push(props.className); + } + + props.className = classes.join(' '); + } + + return props.namespace ? tagName : tagName.toUpperCase(); +} + +},{"browser-split":5}],24:[function(require,module,exports){ +var isVNode = require("./is-vnode") +var isVText = require("./is-vtext") +var isWidget = require("./is-widget") +var isThunk = require("./is-thunk") + +module.exports = handleThunk + +function handleThunk(a, b) { + var renderedA = a + var renderedB = b + + if (isThunk(b)) { + renderedB = renderThunk(b, a) + } + + if (isThunk(a)) { + renderedA = renderThunk(a, null) + } + + return { + a: renderedA, + b: renderedB + } +} + +function renderThunk(thunk, previous) { + var renderedThunk = thunk.vnode + + if (!renderedThunk) { + renderedThunk = thunk.vnode = thunk.render(previous) + } + + if (!(isVNode(renderedThunk) || + isVText(renderedThunk) || + isWidget(renderedThunk))) { + throw new Error("thunk did not return a valid node"); + } + + return renderedThunk +} + +},{"./is-thunk":25,"./is-vnode":27,"./is-vtext":28,"./is-widget":29}],25:[function(require,module,exports){ +module.exports = isThunk + +function isThunk(t) { + return t && t.type === "Thunk" +} + +},{}],26:[function(require,module,exports){ +module.exports = isHook + +function isHook(hook) { + return hook && + (typeof hook.hook === "function" && !hook.hasOwnProperty("hook") || + typeof hook.unhook === "function" && !hook.hasOwnProperty("unhook")) +} + +},{}],27:[function(require,module,exports){ +var version = require("./version") + +module.exports = isVirtualNode + +function isVirtualNode(x) { + return x && x.type === "VirtualNode" && x.version === version +} + +},{"./version":30}],28:[function(require,module,exports){ +var version = require("./version") + +module.exports = isVirtualText + +function isVirtualText(x) { + return x && x.type === "VirtualText" && x.version === version +} + +},{"./version":30}],29:[function(require,module,exports){ +module.exports = isWidget + +function isWidget(w) { + return w && w.type === "Widget" +} + +},{}],30:[function(require,module,exports){ +module.exports = "2" + +},{}],31:[function(require,module,exports){ +var version = require("./version") +var isVNode = require("./is-vnode") +var isWidget = require("./is-widget") +var isThunk = require("./is-thunk") +var isVHook = require("./is-vhook") + +module.exports = VirtualNode + +var noProperties = {} +var noChildren = [] + +function VirtualNode(tagName, properties, children, key, namespace) { + this.tagName = tagName + this.properties = properties || noProperties + this.children = children || noChildren + this.key = key != null ? String(key) : undefined + this.namespace = (typeof namespace === "string") ? namespace : null + + var count = (children && children.length) || 0 + var descendants = 0 + var hasWidgets = false + var hasThunks = false + var descendantHooks = false + var hooks + + for (var propName in properties) { + if (properties.hasOwnProperty(propName)) { + var property = properties[propName] + if (isVHook(property) && property.unhook) { + if (!hooks) { + hooks = {} + } + + hooks[propName] = property + } + } + } + + for (var i = 0; i < count; i++) { + var child = children[i] + if (isVNode(child)) { + descendants += child.count || 0 + + if (!hasWidgets && child.hasWidgets) { + hasWidgets = true + } + + if (!hasThunks && child.hasThunks) { + hasThunks = true + } + + if (!descendantHooks && (child.hooks || child.descendantHooks)) { + descendantHooks = true + } + } else if (!hasWidgets && isWidget(child)) { + if (typeof child.destroy === "function") { + hasWidgets = true + } + } else if (!hasThunks && isThunk(child)) { + hasThunks = true; + } + } + + this.count = count + descendants + this.hasWidgets = hasWidgets + this.hasThunks = hasThunks + this.hooks = hooks + this.descendantHooks = descendantHooks +} + +VirtualNode.prototype.version = version +VirtualNode.prototype.type = "VirtualNode" + +},{"./is-thunk":25,"./is-vhook":26,"./is-vnode":27,"./is-widget":29,"./version":30}],32:[function(require,module,exports){ +var version = require("./version") + +VirtualPatch.NONE = 0 +VirtualPatch.VTEXT = 1 +VirtualPatch.VNODE = 2 +VirtualPatch.WIDGET = 3 +VirtualPatch.PROPS = 4 +VirtualPatch.ORDER = 5 +VirtualPatch.INSERT = 6 +VirtualPatch.REMOVE = 7 +VirtualPatch.THUNK = 8 + +module.exports = VirtualPatch + +function VirtualPatch(type, vNode, patch) { + this.type = Number(type) + this.vNode = vNode + this.patch = patch +} + +VirtualPatch.prototype.version = version +VirtualPatch.prototype.type = "VirtualPatch" + +},{"./version":30}],33:[function(require,module,exports){ +var version = require("./version") + +module.exports = VirtualText + +function VirtualText(text) { + this.text = String(text) +} + +VirtualText.prototype.version = version +VirtualText.prototype.type = "VirtualText" + +},{"./version":30}],34:[function(require,module,exports){ +var isObject = require("is-object") +var isHook = require("../vnode/is-vhook") + +module.exports = diffProps + +function diffProps(a, b) { + var diff + + for (var aKey in a) { + if (!(aKey in b)) { + diff = diff || {} + diff[aKey] = undefined + } + + var aValue = a[aKey] + var bValue = b[aKey] + + if (aValue === bValue) { + continue + } else if (isObject(aValue) && isObject(bValue)) { + if (getPrototype(bValue) !== getPrototype(aValue)) { + diff = diff || {} + diff[aKey] = bValue + } else if (isHook(bValue)) { + diff = diff || {} + diff[aKey] = bValue + } else { + var objectDiff = diffProps(aValue, bValue) + if (objectDiff) { + diff = diff || {} + diff[aKey] = objectDiff + } + } + } else { + diff = diff || {} + diff[aKey] = bValue + } + } + + for (var bKey in b) { + if (!(bKey in a)) { + diff = diff || {} + diff[bKey] = b[bKey] + } + } + + return diff +} + +function getPrototype(value) { + if (Object.getPrototypeOf) { + return Object.getPrototypeOf(value) + } else if (value.__proto__) { + return value.__proto__ + } else if (value.constructor) { + return value.constructor.prototype + } +} + +},{"../vnode/is-vhook":26,"is-object":11}],35:[function(require,module,exports){ +var isArray = require("x-is-array") + +var VPatch = require("../vnode/vpatch") +var isVNode = require("../vnode/is-vnode") +var isVText = require("../vnode/is-vtext") +var isWidget = require("../vnode/is-widget") +var isThunk = require("../vnode/is-thunk") +var handleThunk = require("../vnode/handle-thunk") + +var diffProps = require("./diff-props") + +module.exports = diff + +function diff(a, b) { + var patch = { a: a } + walk(a, b, patch, 0) + return patch +} + +function walk(a, b, patch, index) { + if (a === b) { + return + } + + var apply = patch[index] + var applyClear = false + + if (isThunk(a) || isThunk(b)) { + thunks(a, b, patch, index) + } else if (b == null) { + + // If a is a widget we will add a remove patch for it + // Otherwise any child widgets/hooks must be destroyed. + // This prevents adding two remove patches for a widget. + if (!isWidget(a)) { + clearState(a, patch, index) + apply = patch[index] + } + + apply = appendPatch(apply, new VPatch(VPatch.REMOVE, a, b)) + } else if (isVNode(b)) { + if (isVNode(a)) { + if (a.tagName === b.tagName && + a.namespace === b.namespace && + a.key === b.key) { + var propsPatch = diffProps(a.properties, b.properties) + if (propsPatch) { + apply = appendPatch(apply, + new VPatch(VPatch.PROPS, a, propsPatch)) + } + apply = diffChildren(a, b, patch, apply, index) + } else { + apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b)) + applyClear = true + } + } else { + apply = appendPatch(apply, new VPatch(VPatch.VNODE, a, b)) + applyClear = true + } + } else if (isVText(b)) { + if (!isVText(a)) { + apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b)) + applyClear = true + } else if (a.text !== b.text) { + apply = appendPatch(apply, new VPatch(VPatch.VTEXT, a, b)) + } + } else if (isWidget(b)) { + if (!isWidget(a)) { + applyClear = true + } + + apply = appendPatch(apply, new VPatch(VPatch.WIDGET, a, b)) + } + + if (apply) { + patch[index] = apply + } + + if (applyClear) { + clearState(a, patch, index) + } +} + +function diffChildren(a, b, patch, apply, index) { + var aChildren = a.children + var orderedSet = reorder(aChildren, b.children) + var bChildren = orderedSet.children + + var aLen = aChildren.length + var bLen = bChildren.length + var len = aLen > bLen ? aLen : bLen + + for (var i = 0; i < len; i++) { + var leftNode = aChildren[i] + var rightNode = bChildren[i] + index += 1 + + if (!leftNode) { + if (rightNode) { + // Excess nodes in b need to be added + apply = appendPatch(apply, + new VPatch(VPatch.INSERT, null, rightNode)) + } + } else { + walk(leftNode, rightNode, patch, index) + } + + if (isVNode(leftNode) && leftNode.count) { + index += leftNode.count + } + } + + if (orderedSet.moves) { + // Reorder nodes last + apply = appendPatch(apply, new VPatch( + VPatch.ORDER, + a, + orderedSet.moves + )) + } + + return apply +} + +function clearState(vNode, patch, index) { + // TODO: Make this a single walk, not two + unhook(vNode, patch, index) + destroyWidgets(vNode, patch, index) +} + +// Patch records for all destroyed widgets must be added because we need +// a DOM node reference for the destroy function +function destroyWidgets(vNode, patch, index) { + if (isWidget(vNode)) { + if (typeof vNode.destroy === "function") { + patch[index] = appendPatch( + patch[index], + new VPatch(VPatch.REMOVE, vNode, null) + ) + } + } else if (isVNode(vNode) && (vNode.hasWidgets || vNode.hasThunks)) { + var children = vNode.children + var len = children.length + for (var i = 0; i < len; i++) { + var child = children[i] + index += 1 + + destroyWidgets(child, patch, index) + + if (isVNode(child) && child.count) { + index += child.count + } + } + } else if (isThunk(vNode)) { + thunks(vNode, null, patch, index) + } +} + +// Create a sub-patch for thunks +function thunks(a, b, patch, index) { + var nodes = handleThunk(a, b) + var thunkPatch = diff(nodes.a, nodes.b) + if (hasPatches(thunkPatch)) { + patch[index] = new VPatch(VPatch.THUNK, null, thunkPatch) + } +} + +function hasPatches(patch) { + for (var index in patch) { + if (index !== "a") { + return true + } + } + + return false +} + +// Execute hooks when two nodes are identical +function unhook(vNode, patch, index) { + if (isVNode(vNode)) { + if (vNode.hooks) { + patch[index] = appendPatch( + patch[index], + new VPatch( + VPatch.PROPS, + vNode, + undefinedKeys(vNode.hooks) + ) + ) + } + + if (vNode.descendantHooks || vNode.hasThunks) { + var children = vNode.children + var len = children.length + for (var i = 0; i < len; i++) { + var child = children[i] + index += 1 + + unhook(child, patch, index) + + if (isVNode(child) && child.count) { + index += child.count + } + } + } + } else if (isThunk(vNode)) { + thunks(vNode, null, patch, index) + } +} + +function undefinedKeys(obj) { + var result = {} + + for (var key in obj) { + result[key] = undefined + } + + return result +} + +// List diff, naive left to right reordering +function reorder(aChildren, bChildren) { + // O(M) time, O(M) memory + var bChildIndex = keyIndex(bChildren) + var bKeys = bChildIndex.keys + var bFree = bChildIndex.free + + if (bFree.length === bChildren.length) { + return { + children: bChildren, + moves: null + } + } + + // O(N) time, O(N) memory + var aChildIndex = keyIndex(aChildren) + var aKeys = aChildIndex.keys + var aFree = aChildIndex.free + + if (aFree.length === aChildren.length) { + return { + children: bChildren, + moves: null + } + } + + // O(MAX(N, M)) memory + var newChildren = [] + + var freeIndex = 0 + var freeCount = bFree.length + var deletedItems = 0 + + // Iterate through a and match a node in b + // O(N) time, + for (var i = 0 ; i < aChildren.length; i++) { + var aItem = aChildren[i] + var itemIndex + + if (aItem.key) { + if (bKeys.hasOwnProperty(aItem.key)) { + // Match up the old keys + itemIndex = bKeys[aItem.key] + newChildren.push(bChildren[itemIndex]) + + } else { + // Remove old keyed items + itemIndex = i - deletedItems++ + newChildren.push(null) + } + } else { + // Match the item in a with the next free item in b + if (freeIndex < freeCount) { + itemIndex = bFree[freeIndex++] + newChildren.push(bChildren[itemIndex]) + } else { + // There are no free items in b to match with + // the free items in a, so the extra free nodes + // are deleted. + itemIndex = i - deletedItems++ + newChildren.push(null) + } + } + } + + var lastFreeIndex = freeIndex >= bFree.length ? + bChildren.length : + bFree[freeIndex] + + // Iterate through b and append any new keys + // O(M) time + for (var j = 0; j < bChildren.length; j++) { + var newItem = bChildren[j] + + if (newItem.key) { + if (!aKeys.hasOwnProperty(newItem.key)) { + // Add any new keyed items + // We are adding new items to the end and then sorting them + // in place. In future we should insert new items in place. + newChildren.push(newItem) + } + } else if (j >= lastFreeIndex) { + // Add any leftover non-keyed items + newChildren.push(newItem) + } + } + + var simulate = newChildren.slice() + var simulateIndex = 0 + var removes = [] + var inserts = [] + var simulateItem + + for (var k = 0; k < bChildren.length;) { + var wantedItem = bChildren[k] + simulateItem = simulate[simulateIndex] + + // remove items + while (simulateItem === null && simulate.length) { + removes.push(remove(simulate, simulateIndex, null)) + simulateItem = simulate[simulateIndex] + } + + if (!simulateItem || simulateItem.key !== wantedItem.key) { + // if we need a key in this position... + if (wantedItem.key) { + if (simulateItem && simulateItem.key) { + // if an insert doesn't put this key in place, it needs to move + if (bKeys[simulateItem.key] !== k + 1) { + removes.push(remove(simulate, simulateIndex, simulateItem.key)) + simulateItem = simulate[simulateIndex] + // if the remove didn't put the wanted item in place, we need to insert it + if (!simulateItem || simulateItem.key !== wantedItem.key) { + inserts.push({key: wantedItem.key, to: k}) + } + // items are matching, so skip ahead + else { + simulateIndex++ + } + } + else { + inserts.push({key: wantedItem.key, to: k}) + } + } + else { + inserts.push({key: wantedItem.key, to: k}) + } + k++ + } + // a key in simulate has no matching wanted key, remove it + else if (simulateItem && simulateItem.key) { + removes.push(remove(simulate, simulateIndex, simulateItem.key)) + } + } + else { + simulateIndex++ + k++ + } + } + + // remove all the remaining nodes from simulate + while(simulateIndex < simulate.length) { + simulateItem = simulate[simulateIndex] + removes.push(remove(simulate, simulateIndex, simulateItem && simulateItem.key)) + } + + // If the only moves we have are deletes then we can just + // let the delete patch remove these items. + if (removes.length === deletedItems && !inserts.length) { + return { + children: newChildren, + moves: null + } + } + + return { + children: newChildren, + moves: { + removes: removes, + inserts: inserts + } + } +} + +function remove(arr, index, key) { + arr.splice(index, 1) + + return { + from: index, + key: key + } +} + +function keyIndex(children) { + var keys = {} + var free = [] + var length = children.length + + for (var i = 0; i < length; i++) { + var child = children[i] + + if (child.key) { + keys[child.key] = i + } else { + free.push(i) + } + } + + return { + keys: keys, // A hash of key name to index + free: free // An array of unkeyed item indices + } +} + +function appendPatch(apply, patch) { + if (apply) { + if (isArray(apply)) { + apply.push(patch) + } else { + apply = [apply, patch] + } + + return apply + } else { + return patch + } +} + +},{"../vnode/handle-thunk":24,"../vnode/is-thunk":25,"../vnode/is-vnode":27,"../vnode/is-vtext":28,"../vnode/is-widget":29,"../vnode/vpatch":32,"./diff-props":34,"x-is-array":12}]},{},[4])(4) +});