diff --git a/.eslintrc b/.eslintrc index d2a13ae262d..2b3ed3ba7df 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 5b16593a82c..ab0a429832b 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -152,21 +152,20 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, { }) }); -function proxyDep(propName, moduleFunc, msg) { - if (Discourse.hasOwnProperty(propName)) { return; } - Object.defineProperty(Discourse, propName, { - get: function() { - msg = msg || "import the module"; - Ember.warn("DEPRECATION: `Discourse." + propName + "` is deprecated, " + msg + "."); - return moduleFunc(); - } - }); +function RemovedObject(name) { + this._removedName = name; } -proxyDep('computed', function() { return require('discourse/lib/computed'); }); -proxyDep('Formatter', function() { return require('discourse/lib/formatter'); }); -proxyDep('PageTracker', function() { return require('discourse/lib/page-tracker').default; }); -proxyDep('URL', function() { return require('discourse/lib/url').default; }); -proxyDep('Quote', function() { return require('discourse/lib/quote').default; }); -proxyDep('debounce', function() { return require('discourse/lib/debounce').default; }); -proxyDep('View', function() { return Ember.View; }, "Use `Ember.View` instead"); +function methodMissing() { + console.warn("The " + this._removedName + " object has been removed from Discourse " + + "and your plugin needs to be updated."); +}; + +Discourse.RemovedObject = RemovedObject; + +['reopen', 'registerButton', 'on', 'off'].forEach(function(m) { RemovedObject.prototype[m] = methodMissing; }); + +['discourse/views/post', 'discourse/components/post-menu'].forEach(function(moduleName) { + define(moduleName, [], function() { return new RemovedObject(moduleName); }); +}); + 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 00000000000..335c22b6b54 --- /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 00000000000..f36299d002f --- /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 533438852cf..00000000000 --- 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/bread-crumbs.js.es6 b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 index 66fcd65a378..c1cdb1f55a7 100644 --- a/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 +++ b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 @@ -12,8 +12,8 @@ export default Ember.Component.extend({ return !c.get('parentCategory'); }), - hidden: function(){ - return Discourse.Mobile.mobileView && !this.get('category'); + hidden: function() { + return this.site.mobileView && !this.get('category'); }.property('category'), firstCategory: function() { diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index ddc86134cf6..816a7e37ded 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -18,7 +18,7 @@ export default Ember.Component.extend({ @on('init') _setupPreview() { - const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); + const val = (this.site.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); this.set('showPreview', val === 'true'); }, @@ -91,6 +91,8 @@ export default Ember.Component.extend({ _syncEditorAndPreviewScroll() { const $input = this.$('.d-editor-input'); + if (!$input) { return; } + const $preview = this.$('.d-editor-preview'); if ($input.scrollTop() === 0) { @@ -216,7 +218,7 @@ export default Ember.Component.extend({ } }); - if (Discourse.Mobile.mobileView) { + if (this.site.mobileView) { this.$(".mobile-file-upload").on("click.uploader", function () { // redirect the click on the hidden file input $("#mobile-uploader").click(); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index e81f14c212a..76f51ada849 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -25,156 +25,164 @@ const OP = { const _createCallbacks = []; -function Toolbar() { - this.shortcuts = {}; +class Toolbar { - this.groups = [ - {group: 'fontStyles', buttons: []}, - {group: 'insertions', buttons: []}, - {group: 'extras', buttons: []} - ]; + constructor(site) { + this.shortcuts = {}; - this.addButton({ - id: 'bold', - group: 'fontStyles', - shortcut: 'B', - perform: e => e.applySurround('**', '**', 'bold_text') - }); - - this.addButton({ - id: 'italic', - group: 'fontStyles', - shortcut: 'I', - perform: e => e.applySurround('_', '_', 'italic_text') - }); - - this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'}); - - this.addButton({ - id: 'quote', - group: 'insertions', - icon: 'quote-right', - shortcut: 'Shift+9', - perform: e => e.applySurround('> ', '', 'code_text') - }); - - this.addButton({ - id: 'code', - group: 'insertions', - shortcut: 'Shift+C', - perform(e) { - if (e.selected.value.indexOf("\n") !== -1) { - e.applySurround(' ', '', 'code_text'); - } else { - e.applySurround('`', '`', 'code_text'); - } - }, - }); - - this.addButton({ - id: 'bullet', - group: 'extras', - icon: 'list-ul', - shortcut: 'Shift+8', - title: 'composer.ulist_title', - perform: e => e.applyList('* ', 'list_item') - }); - - this.addButton({ - id: 'list', - group: 'extras', - icon: 'list-ol', - shortcut: 'Shift+7', - title: 'composer.olist_title', - perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item') - }); - - this.addButton({ - id: 'heading', - group: 'extras', - icon: 'font', - shortcut: 'Alt+1', - perform: e => e.applyList('## ', 'heading_text') - }); - - this.addButton({ - id: 'rule', - group: 'extras', - icon: 'minus', - shortcut: 'Alt+R', - title: 'composer.hr_title', - perform: e => e.addText("\n\n----------\n") - }); - - if (Discourse.Mobile.mobileView) { - this.groups.push({group: 'mobileExtras', buttons: []}); + this.groups = [ + {group: 'fontStyles', buttons: []}, + {group: 'insertions', buttons: []}, + {group: 'extras', buttons: []} + ]; this.addButton({ - id: 'preview', - group: 'mobileExtras', - icon: 'television', - title: 'composer.hr_preview', - perform: e => e.preview() + id: 'bold', + group: 'fontStyles', + shortcut: 'B', + perform: e => e.applySurround('**', '**', 'bold_text') }); - } - this.groups[this.groups.length-1].lastGroup = true; -}; + this.addButton({ + id: 'italic', + group: 'fontStyles', + shortcut: 'I', + perform: e => e.applySurround('_', '_', 'italic_text') + }); -Toolbar.prototype.addButton = function(button) { - const g = this.groups.findProperty('group', button.group); - if (!g) { - throw `Couldn't find toolbar group ${button.group}`; - } + this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'}); - const createdButton = { - id: button.id, - className: button.className || button.id, - icon: button.icon || button.id, - action: button.action || 'toolbarButton', - perform: button.perform || Ember.K - }; + this.addButton({ + id: 'quote', + group: 'insertions', + icon: 'quote-right', + shortcut: 'Shift+9', + perform: e => e.applySurround('> ', '', 'code_text') + }); - if (button.sendAction) { - createdButton.sendAction = button.sendAction; - } + this.addButton({ + id: 'code', + group: 'insertions', + shortcut: 'Shift+C', + perform(e) { + if (e.selected.value.indexOf("\n") !== -1) { + e.applySurround(' ', '', 'code_text'); + } else { + e.applySurround('`', '`', 'code_text'); + } + }, + }); - const title = I18n.t(button.title || `composer.${button.id}_title`); - if (button.shortcut) { - const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); - const mod = mac ? 'Meta' : 'Ctrl'; - var shortcutTitle = `${mod}+${button.shortcut}`; + this.addButton({ + id: 'bullet', + group: 'extras', + icon: 'list-ul', + shortcut: 'Shift+8', + title: 'composer.ulist_title', + perform: e => e.applyList('* ', 'list_item') + }); - // Mac users are used to glyphs for shortcut keys - if (mac) { - shortcutTitle = shortcutTitle - .replace('Shift', "\u21E7") - .replace('Meta', "\u2318") - .replace('Alt', "\u2325") - .replace(/\+/g, ''); - } else { - shortcutTitle = shortcutTitle - .replace('Shift', I18n.t('shortcut_modifier_key.shift')) - .replace('Ctrl', I18n.t('shortcut_modifier_key.ctrl')) - .replace('Alt', I18n.t('shortcut_modifier_key.alt')); + this.addButton({ + id: 'list', + group: 'extras', + icon: 'list-ol', + shortcut: 'Shift+7', + title: 'composer.olist_title', + perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item') + }); + + this.addButton({ + id: 'heading', + group: 'extras', + icon: 'font', + shortcut: 'Alt+1', + perform: e => e.applyList('## ', 'heading_text') + }); + + this.addButton({ + id: 'rule', + group: 'extras', + icon: 'minus', + shortcut: 'Alt+R', + title: 'composer.hr_title', + perform: e => e.addText("\n\n----------\n") + }); + + if (site.mobileView) { + this.groups.push({group: 'mobileExtras', buttons: []}); + + this.addButton({ + id: 'preview', + group: 'mobileExtras', + icon: 'television', + title: 'composer.hr_preview', + perform: e => e.preview() + }); } - createdButton.title = `${title} (${shortcutTitle})`; - - this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton; - } else { - createdButton.title = title; + this.groups[this.groups.length-1].lastGroup = true; } - if (button.unshift) { - g.buttons.unshift(createdButton); - } else { - g.buttons.push(createdButton); + addButton(button) { + const g = this.groups.findProperty('group', button.group); + if (!g) { + throw `Couldn't find toolbar group ${button.group}`; + } + + const createdButton = { + id: button.id, + className: button.className || button.id, + icon: button.icon || button.id, + action: button.action || 'toolbarButton', + perform: button.perform || Ember.K + }; + + if (button.sendAction) { + createdButton.sendAction = button.sendAction; + } + + const title = I18n.t(button.title || `composer.${button.id}_title`); + if (button.shortcut) { + const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const mod = mac ? 'Meta' : 'Ctrl'; + var shortcutTitle = `${mod}+${button.shortcut}`; + + // Mac users are used to glyphs for shortcut keys + if (mac) { + shortcutTitle = shortcutTitle + .replace('Shift', "\u21E7") + .replace('Meta', "\u2318") + .replace('Alt', "\u2325") + .replace(/\+/g, ''); + } else { + shortcutTitle = shortcutTitle + .replace('Shift', I18n.t('shortcut_modifier_key.shift')) + .replace('Ctrl', I18n.t('shortcut_modifier_key.ctrl')) + .replace('Alt', I18n.t('shortcut_modifier_key.alt')); + } + + createdButton.title = `${title} (${shortcutTitle})`; + + this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton; + } else { + createdButton.title = title; + } + + if (button.unshift) { + g.buttons.unshift(createdButton); + } else { + g.buttons.push(createdButton); + } } -}; +} + +export function addToolbarCallback(func) { + _createCallbacks.push(func); +} export function onToolbarCreate(func) { - _createCallbacks.push(func); + console.warn('`onToolbarCreate` is deprecated, use the plugin api instead.'); + addToolbarCallback(func); }; export default Ember.Component.extend({ @@ -237,7 +245,7 @@ export default Ember.Component.extend({ @computed toolbar() { - const toolbar = new Toolbar(); + const toolbar = new Toolbar(this.site); _createCallbacks.forEach(cb => cb(toolbar)); this.sendAction('extraButtons', toolbar); return toolbar; diff --git a/app/assets/javascripts/discourse/components/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/components/hamburger-menu.js.es6 index e10c01c84d8..9ec933bcd69 100644 --- a/app/assets/javascripts/discourse/components/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/components/hamburger-menu.js.es6 @@ -10,17 +10,17 @@ export default Ember.Component.extend({ @computed() showKeyboardShortcuts() { - return !Discourse.Mobile.mobileView && !this.capabilities.touch; + return !this.site.mobileView && !this.capabilities.touch; }, @computed() showMobileToggle() { - return Discourse.Mobile.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch); + return this.site.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch); }, @computed() mobileViewLinkTextKey() { - return Discourse.Mobile.mobileView ? "desktop_view" : "mobile_view"; + return this.site.mobileView ? "desktop_view" : "mobile_view"; }, @computed() @@ -68,7 +68,7 @@ export default Ember.Component.extend({ this.sendAction('showKeyboardAction'); }, toggleMobileView() { - Discourse.Mobile.toggleMobileView(); + this.site.toggleMobileView(); } } }); diff --git a/app/assets/javascripts/discourse/components/header-dropdown.js.es6 b/app/assets/javascripts/discourse/components/header-dropdown.js.es6 index 7a3f52f4552..c7479848554 100644 --- a/app/assets/javascripts/discourse/components/header-dropdown.js.es6 +++ b/app/assets/javascripts/discourse/components/header-dropdown.js.es6 @@ -17,7 +17,7 @@ export default Ember.Component.extend({ if (this.siteSettings.login_required && !this.currentUser) { this.sendAction('loginAction'); } else { - if (Discourse.Mobile.mobileView && this.get('mobileAction')) { + if (this.site.mobileView && this.get('mobileAction')) { this.sendAction('mobileAction'); return; } diff --git a/app/assets/javascripts/discourse/components/home-logo.js.es6 b/app/assets/javascripts/discourse/components/home-logo.js.es6 index 1cd706d09ed..c0846e268b8 100644 --- a/app/assets/javascripts/discourse/components/home-logo.js.es6 +++ b/app/assets/javascripts/discourse/components/home-logo.js.es6 @@ -14,11 +14,11 @@ export default Ember.Component.extend({ }.property('targetUrl'), showSmallLogo: function() { - return !Discourse.Mobile.mobileView && this.get("minimized"); + return !this.site.mobileView && this.get("minimized"); }.property("minimized"), showMobileLogo: function() { - return Discourse.Mobile.mobileView && !Ember.isBlank(this.get('mobileBigLogoUrl')); + return this.site.mobileView && !Ember.isBlank(this.get('mobileBigLogoUrl')); }.property(), smallLogoUrl: setting('logo_small_url'), diff --git a/app/assets/javascripts/discourse/components/menu-panel.js.es6 b/app/assets/javascripts/discourse/components/menu-panel.js.es6 index 372df605b54..ca017a1171f 100644 --- a/app/assets/javascripts/discourse/components/menu-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/menu-panel.js.es6 @@ -120,17 +120,17 @@ export default Ember.Component.extend({ @computed() showKeyboardShortcuts() { - return !Discourse.Mobile.mobileView && !this.capabilities.touch; + return !this.site.mobileView && !this.capabilities.touch; }, @computed() showMobileToggle() { - return Discourse.Mobile.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch); + return this.site.mobileView || (this.siteSettings.enable_mobile_theme && this.capabilities.touch); }, @computed() mobileViewLinkTextKey() { - return Discourse.Mobile.mobileView ? "desktop_view" : "mobile_view"; + return this.site.mobileView ? "desktop_view" : "mobile_view"; }, @computed() 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 00000000000..bfadbd059f9 --- /dev/null +++ b/app/assets/javascripts/discourse/components/mount-widget.js.es6 @@ -0,0 +1,74 @@ +import { diff, patch } from 'virtual-dom'; +import { WidgetClickHook } from 'discourse/widgets/click-hook'; +import { renderedKey } from 'discourse/widgets/widget'; + +const _cleanCallbacks = {}; +export function addWidgetCleanCallback(widgetName, fn) { + _cleanCallbacks[widgetName] = _cleanCallbacks[widgetName] || []; + _cleanCallbacks[widgetName].push(fn); +} + +export default Ember.Component.extend({ + _tree: null, + _rootNode: null, + _timeout: null, + _widgetClass: null, + _afterRender: null, + + init() { + this._super(); + this._widgetClass = this.container.lookupFactory(`widget:${this.get('widget')}`); + this._connected = []; + }, + + didInsertElement() { + WidgetClickHook.setupDocumentCallback(); + + this._rootNode = document.createElement('div'); + this.element.appendChild(this._rootNode); + this._timeout = Ember.run.scheduleOnce('render', this, this.rerenderWidget); + }, + + willClearRender() { + const callbacks = _cleanCallbacks[this.get('widget')]; + if (callbacks) { + callbacks.forEach(cb => cb()); + } + + this._connected.forEach(v => v.destroy()); + this._connected.length = 0; + }, + + willDestroyElement() { + Ember.run.cancel(this._timeout); + }, + + queueRerender(callback) { + if (callback && !this._afterRender) { + this._afterRender = callback; + } + + Ember.run.scheduleOnce('render', this, this.rerenderWidget); + }, + + rerenderWidget() { + Ember.run.cancel(this._timeout); + if (this._rootNode) { + const opts = { model: this.get('model') }; + const newTree = new this._widgetClass(this.get('args'), this.container, opts); + + newTree._emberView = this; + const patches = diff(this._tree || this._rootNode, newTree); + this._rootNode = patch(this._rootNode, patches); + this._tree = newTree; + + if (this._afterRender) { + this._afterRender(); + this._afterRender = null; + } + + renderedKey('*'); + } + } + +}); 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 159d047f076..00000000000 --- 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 9889184d1ff..00000000000 --- 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 ef63cd9d248..00000000000 --- 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 index 7f213f7c00e..15ca5404049 100644 --- a/app/assets/javascripts/discourse/components/poster-name.js.es6 +++ b/app/assets/javascripts/discourse/components/poster-name.js.es6 @@ -1,77 +1,2 @@ -import { setting } from 'discourse/lib/computed'; - -const PosterNameComponent = Em.Component.extend({ - classNames: ['names', 'trigger-user-card'], - displayNameOnPosts: setting('display_name_on_posts'), - - // sanitize name for comparison - sanitizeName(name){ - return name.toLowerCase().replace(/[\s_-]/g,''); - }, - - render(buffer) { - const post = this.get('post'); - - if (post) { - const username = post.get('username'), - primaryGroupName = post.get('primary_group_name'), - url = post.get('usernameUrl'); - - var linkClass = 'username', - name = post.get('name'); - - if (post.get('staff')) { linkClass += ' staff'; } - if (post.get('admin')) { linkClass += ' admin'; } - if (post.get('moderator')) { linkClass += ' moderator'; } - if (post.get('new_user')) { linkClass += ' new-user'; } - - if (!Em.isEmpty(primaryGroupName)) { - linkClass += ' ' + primaryGroupName; - } - // Main link - buffer.push("" + 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; +const removed = new Discourse.RemovedObject('discourse/components/poster-name'); +export default removed; 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 0d01910c722..00000000000 --- 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 00000000000..b18152e8c15 --- /dev/null +++ b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 @@ -0,0 +1,176 @@ +import DiscourseURL from 'discourse/lib/url'; +import { keyDirty } from 'discourse/widgets/widget'; +import MountWidget from 'discourse/components/mount-widget'; + +function findTopView($posts, viewportTop, min, max) { + if (max < min) { return min; } + + while(max>min){ + const mid = Math.floor((min + max) / 2); + const $post = $($posts[mid]); + const viewBottom = $post.position().top + $post.height(); + + if (viewBottom > viewportTop) { + max = mid-1; + } else { + min = mid+1; + } + } + + return min; +} + +export default MountWidget.extend({ + widget: 'post-stream', + _topVisible: null, + _bottomVisible: null, + + args: Ember.computed(function() { + return this.getProperties('posts', + 'canCreatePost', + 'multiSelect', + 'gaps', + 'selectedQuery', + 'selectedPostsCount', + 'searchService'); + }).volatile(), + + scrolled() { + if (this.isDestroyed || this.isDestroying) { return; } + + const $w = $(window); + const windowHeight = window.innerHeight ? window.innerHeight : $w.height(); + const slack = Math.round(windowHeight * 15); + const onscreen = []; + + let windowTop = $w.scrollTop(); + + const $posts = this.$('.onscreen-post'); + const viewportTop = windowTop - slack; + const topView = findTopView($posts, viewportTop, 0, $posts.length-1); + + let windowBottom = windowTop + windowHeight; + let viewportBottom = windowBottom + slack; + + const bodyHeight = $('body').height(); + if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } + if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; } + + let bottomView = topView; + while (bottomView < $posts.length) { + const post = $posts[bottomView]; + const $post = $(post); + + if (!$post) { break; } + + const viewTop = $post.offset().top; + const viewBottom = viewTop + $post.height(); + + if (viewTop > viewportBottom) { break; } + + if (viewBottom > windowTop && viewTop <= windowBottom) { + onscreen.push(bottomView); + } + + bottomView++; + } + + const posts = this.posts; + if (onscreen.length) { + + const refresh = cb => this.queueRerender(cb); + const first = posts.objectAt(onscreen[0]); + if (this._topVisible !== first) { + this._topVisible = first; + const $body = $('body'); + const elem = $posts[onscreen[0]]; + const elemId = elem.id; + const $elem = $(elem); + const elemPos = $elem.position(); + const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0; + + const topRefresh = () => { + refresh(() => { + const $refreshedElem = $(`#${elemId}`); + + // Quickly going back might mean the element is destroyed + const position = $refreshedElem.position(); + if (position && position.top) { + $('html, body').scrollTop(position.top + distToElement); + } + }); + }; + this.sendAction('topVisibleChanged', { post: first, refresh: topRefresh }); + } + + const last = posts.objectAt(onscreen[onscreen.length-1]); + if (this._bottomVisible !== last) { + this._bottomVisible = last; + this.sendAction('bottomVisibleChanged', { post: last, refresh }); + } + } else { + this._topVisible = null; + this._bottomVisible = null; + } + + const onscreenPostNumbers = onscreen.map(idx => posts.objectAt(idx).post_number); + this.screenTrack.setOnscreen(onscreenPostNumbers); + }, + + _scrollTriggered() { + Ember.run.scheduleOnce('afterRender', this, this.scrolled); + }, + + didInsertElement() { + this._super(); + const debouncedScroll = () => Ember.run.debounce(this, this._scrollTriggered, 10); + + this.appEvents.on('post-stream:refresh', debouncedScroll); + $(document).bind('touchmove.post-stream', debouncedScroll); + $(window).bind('scroll.post-stream', debouncedScroll); + this._scrollTriggered(); + + this.appEvents.on('post-stream:posted', staged => { + const disableJumpReply = this.currentUser.get('disable_jump_reply'); + + this.queueRerender(() => { + if (staged && !disableJumpReply) { + const postNumber = staged.get('post_number'); + DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true }); + } + }); + }); + + this.$().on('mouseenter.post-stream', 'button.widget-button', e => { + $('button.widget-button').removeClass('d-hover'); + $(e.target).addClass('d-hover'); + }); + + this.$().on('mouseleave.post-stream', 'button.widget-button', () => { + $('button.widget-button').removeClass('d-hover'); + }); + + this.appEvents.on('post-stream:refresh', args => { + if (args) { + if (args.id) { + keyDirty(`post-${args.id}`); + } else if (args.force) { + keyDirty(`*`); + } + } + this.queueRerender(); + }); + }, + + willDestroyElement() { + this._super(); + $(document).unbind('touchmove.post-stream'); + $(window).unbind('scroll.post-stream'); + this.appEvents.off('post-stream:refresh'); + this.$().off('mouseenter.post-stream'); + this.$().off('mouseleave.post-stream'); + this.appEvents.off('post-stream:refresh'); + this.appEvents.off('post-stream:posted'); + } + +}); diff --git a/app/assets/javascripts/discourse/components/small-action.js.es6 b/app/assets/javascripts/discourse/components/small-action.js.es6 index 56743634893..b66024987eb 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 9e410c4b73e..00000000000 --- 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 45dad266014..00000000000 --- 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-list.js.es6 b/app/assets/javascripts/discourse/components/topic-list.js.es6 index 2cc0747b0ed..03a6a4a0a73 100644 --- a/app/assets/javascripts/discourse/components/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list.js.es6 @@ -19,7 +19,7 @@ export default Ember.Component.extend({ }.property(), skipHeader: function() { - return Discourse.Mobile.mobileView; + return this.site.mobileView; }.property(), showLikes: function(){ 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 b09d1a2d8e2..00000000000 --- 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 e8e58ea34f2..00000000000 --- 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 412dfecdeb0..00000000000 --- 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 88e6c532be8..e2adfe819d7 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -263,7 +263,6 @@ export default Ember.Controller.extend({ } var staged = false; - const disableJumpReply = Discourse.User.currentProp('disable_jump_reply'); // TODO: This should not happen in model const imageSizes = {}; @@ -281,6 +280,7 @@ export default Ember.Controller.extend({ self.send('postWasEnqueued', result.responseJson); self.destroyDraft(); self.close(); + self.appEvents.trigger('post-stream:refresh'); return result; } @@ -288,7 +288,15 @@ export default Ember.Controller.extend({ if (result.responseJson.action === "create_post" || self.get('replyAsNewTopicDraft')) { self.destroyDraft(); } + if (self.get('model.action') === 'edit') { + self.appEvents.trigger('post-stream:refresh', { id: parseInt(result.responseJson.id) }); + } else { + self.appEvents.trigger('post-stream:refresh'); + } + if (result.responseJson.action === "create_post") { + self.appEvents.trigger('post:highlight', result.payload.post_number); + } self.close(); const currentUser = Discourse.User.current(); @@ -298,14 +306,14 @@ export default Ember.Controller.extend({ currentUser.set('reply_count', currentUser.get('reply_count') + 1); } - // TODO disableJumpReply is super crude, it needs to provide some sort - // of notification to the end user + const disableJumpReply = Discourse.User.currentProp('disable_jump_reply'); if (!composer.get('replyingToTopic') || !disableJumpReply) { const post = result.target; if (post && !staged) { DiscourseURL.routeTo(post.get('url')); } } + }).catch(function(error) { composer.set('disableDrafts', false); self.appEvents.one('composer:opened', () => bootbox.alert(error)); @@ -316,18 +324,10 @@ export default Ember.Controller.extend({ staged = composer.get('stagedPost'); } - Em.run.schedule('afterRender', function() { - if (staged && !disableJumpReply) { - const postNumber = staged.get('post_number'); - DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true }); - self.appEvents.trigger('post:highlight', postNumber); - } - }); + this.appEvents.trigger('post-stream:posted', staged); this.messageBus.pause(); - promise.finally(function(){ - self.messageBus.resume(); - }); + promise.finally(() => this.messageBus.resume()); return promise; }, @@ -587,14 +587,6 @@ export default Ember.Controller.extend({ $('.d-editor-input').autocomplete({ cancel: true }); }, - showOptions() { - var _ref; - return (_ref = this.get('controllers.modal')) ? _ref.show(Discourse.ArchetypeOptionsModalView.create({ - archetype: this.get('model.archetype'), - metaData: this.get('model.metaData') - })) : void 0; - }, - canEdit: function() { return this.get("model.action") === "edit" && Discourse.User.current().get("can_edit"); }.property("model.action"), diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index ea3bc834e2f..c598bc682b9 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -89,7 +89,6 @@ export default Ember.Controller.extend(ModalFunctionality, { }, createFlag(opts) { - const self = this; let postAction; // an instance of ActionSummary if (!this.get('flagTopic')) { @@ -103,13 +102,14 @@ export default Ember.Controller.extend(ModalFunctionality, { this.send('hideModal'); - postAction.act(this.get('model'), params).then(function() { - self.send('closeModal'); + postAction.act(this.get('model'), params).then(() => { + this.send('closeModal'); if (params.message) { - self.set('message', ''); + this.set('message', ''); } - }, function(errors) { - self.send('closeModal'); + this.appEvents.trigger('post-stream:refresh', { id: this.get('model.id') }); + }).catch(errors => { + this.send('closeModal'); if (errors && errors.responseText) { bootbox.alert($.parseJSON(errors.responseText).errors); } else { diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 0a3d504344a..f799700046d 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -10,7 +10,7 @@ export default Ember.Controller.extend(ModalFunctionality, { revisionsTextKey: "post.revisions.controls.comparing_previous_to_current_out_of_total", _changeViewModeOnMobile: function() { - if (Discourse.Mobile.mobileView) { this.set("viewMode", "inline"); } + if (this.site.mobileView) { this.set("viewMode", "inline"); } }.on("init"), refresh(postId, postVersion) { diff --git a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 index cfff408844d..51f89381aed 100644 --- a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 @@ -61,8 +61,10 @@ export default Ember.Controller.extend({ // containing a single invisible character markerElement.appendChild(document.createTextNode("\ufeff")); + const isMobileDevice = this.site.isMobileDevice; + // collapse the range at the beginning/end of the selection - range.collapse(!Discourse.Mobile.isMobileDevice); + range.collapse(!isMobileDevice); // and insert it at the start of our selection range range.insertNode(markerElement); @@ -83,7 +85,7 @@ export default Ember.Controller.extend({ let topOff = markerOffset.top; let leftOff = markerOffset.left; - if (Discourse.Mobile.isMobileDevice) { + if (isMobileDevice) { topOff = topOff + 20; leftOff = Math.min(leftOff + 10, $(window).width() - $quoteButton.outerWidth()); } else { diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 2fe9e6ee7bf..69edd8a1c6a 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,64 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { DiscourseURL.routeTo(url); }, + selectedQuery: function() { + return post => this.postSelected(post); + }.property(), + actions: { + + fillGapBefore(args) { + return this.get('model.postStream').fillGapBefore(args.post, args.gap); + }, + + fillGapAfter(args) { + return this.get('model.postStream').fillGapAfter(args.post, args.gap); + }, + + // Called the the topmost visible post on the page changes. + topVisibleChanged(event) { + const { post, refresh } = event; + + if (!post) { return; } + + const postStream = this.get('model.postStream'); + const firstLoadedPost = postStream.get('posts.firstObject'); + + const currentPostNumber = post.get('post_number'); + this.set('model.currentPost', currentPostNumber); + this.send('postChangedRoute', currentPostNumber); + + if (post.get('post_number') === 1) { return; } + + if (firstLoadedPost && firstLoadedPost === post) { + postStream.prependMore().then(() => refresh()); + } + }, + + // Called the the bottommost visible post on the page changes. + bottomVisibleChanged(event) { + const { post, refresh } = event; + + const postStream = this.get('model.postStream'); + const lastLoadedPost = postStream.get('posts.lastObject'); + + this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post)); + + if (lastLoadedPost && lastLoadedPost === post && postStream.get('canAppendMore')) { + postStream.appendMore().then(() => refresh()); + // show loading stuff + refresh(); + } + }, + + toggleSummary() { + return this.get('model.postStream').toggleSummary(); + }, + + removeAllowedUser(user) { + return this.get('model.details').removeAllowedUser(user); + }, + showTopicAdminMenu() { this.set('adminMenuVisible', true); }, @@ -113,7 +169,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this.deleteTopic(); }, - archiveMessage() { const topic = this.get('model'); topic.archiveMessage().then(()=>{ @@ -176,8 +231,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // Deleting the first post deletes the topic if (post.get('post_number') === 1) { - this.deleteTopic(); - return; + return this.deleteTopic(); } else if (!post.can_delete) { // check if current user can delete post return false; @@ -210,7 +264,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } ]); } else { - post.destroy(user).catch(function(error) { + return post.destroy(user).then(() => { + this.appEvents.trigger('post-stream:refresh'); + }).catch(error => { popupAjaxError(error); post.undoDeleteState(); }); @@ -245,14 +301,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, toggleBookmark(post) { - if (!Discourse.User.current()) { + if (!this.currentUser) { alert(I18n.t("bookmarks.not_bookmarked")); return; } if (post) { return post.toggleBookmark().catch(popupAjaxError); } else { - return this.get("model").toggleBookmark(); + return this.get("model").toggleBookmark().then(changedIds => { + if (!changedIds) { return; } + changedIds.forEach(id => this.appEvents.trigger('post-stream:refresh', { id })); + }); } }, @@ -261,18 +320,20 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, selectAll() { - const posts = this.get('model.postStream.posts'), - selectedPosts = this.get('selectedPosts'); + const posts = this.get('model.postStream.posts'); + const selectedPosts = this.get('selectedPosts'); if (posts) { selectedPosts.addObjects(posts); } this.set('allPostsSelected', true); + this.appEvents.trigger('post-stream:refresh', { force: true }); }, deselectAll() { this.get('selectedPosts').clear(); this.get('selectedReplies').clear(); this.set('allPostsSelected', false); + this.appEvents.trigger('post-stream:refresh', { force: true }); }, toggleParticipant(user) { @@ -293,6 +354,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { toggleMultiSelect() { this.toggleProperty('multiSelect'); + this.appEvents.trigger('post-stream:refresh', { force: true }); }, finishedEditingTopic() { @@ -324,27 +386,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, deleteSelected() { - const self = this; - bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) { + bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), result => { if (result) { // If all posts are selected, it's the same thing as deleting the topic - if (self.get('allPostsSelected')) { - return self.deleteTopic(); + if (this.get('allPostsSelected')) { + return this.deleteTopic(); } - const selectedPosts = self.get('selectedPosts'), - selectedReplies = self.get('selectedReplies'), - postStream = self.get('model.postStream'), - toRemove = []; + const selectedPosts = this.get('selectedPosts'); + const selectedReplies = this.get('selectedReplies'); + const postStream = this.get('model.postStream'); Discourse.Post.deleteMany(selectedPosts, selectedReplies); - postStream.get('posts').forEach(function (p) { - if (self.postSelected(p)) { toRemove.addObject(p); } + postStream.get('posts').forEach(p => { + if (this.postSelected(p)) { + p.set('deleted_at', new Date()); + } }); - postStream.removePosts(toRemove); - self.send('toggleMultiSelect'); + this.send('toggleMultiSelect'); } }); }, @@ -447,18 +508,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }); }, - expandFirstPost(post) { - const self = this; - this.set('loadingExpanded', true); - post.expand().then(function() { - self.set('firstPostExpanded', true); - }).catch(function(error) { - bootbox.alert($.parseJSON(error.responseText).errors); - }).finally(function() { - self.set('loadingExpanded', false); - }); - }, - retryLoading() { const self = this; self.set('retrying', true); @@ -470,22 +519,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, toggleWiki(post) { - post.updatePostField('wiki', !post.get('wiki')); + return post.updatePostField('wiki', !post.get('wiki')); }, togglePostType(post) { const regular = this.site.get('post_types.regular'); const moderator = this.site.get('post_types.moderator_action'); - post.updatePostField('post_type', post.get('post_type') === moderator ? regular : moderator); + return post.updatePostField('post_type', post.get('post_type') === moderator ? regular : moderator); }, rebakePost(post) { - post.rebake(); + return post.rebake(); }, unhidePost(post) { - post.unhide(); + return post.unhide(); }, changePostOwner(post) { @@ -498,11 +547,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this.send('togglePinnedForUser'); }, - showExpandButton: function() { - const post = this.get('post'); - return post.get('post_number') === 1 && post.get('topic.expandable_first_post'); - }.property(), - canMergeTopic: function() { if (!this.get('model.details.can_move_posts')) return false; return this.get('selectedPostsCount') > 0; @@ -598,9 +642,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // Unsubscribe before subscribing again this.unsubscribe(); - const self = this; - this.messageBus.subscribe("/topic/" + this.get('model.id'), function(data) { - const topic = self.get('model'); + const refresh = (id) => this.appEvents.trigger('post-stream:refresh', { id }); + + this.messageBus.subscribe("/topic/" + this.get('model.id'), data => { + const topic = this.get('model'); if (data.notification_level_change) { topic.set('details.notification_level', data.notification_level_change); @@ -608,26 +653,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return; } - const postStream = self.get('model.postStream'); + const postStream = this.get('model.postStream'); switch (data.type) { case "revised": case "acted": case "rebaked": { // TODO we could update less data for "acted" (only post actions) - postStream.triggerChangedPost(data.id, data.updated_at); + postStream.triggerChangedPost(data.id, data.updated_at).then(() => refresh(data.id)); return; } case "deleted": { - postStream.triggerDeletedPost(data.id, data.post_number); + postStream.triggerDeletedPost(data.id, data.post_number).then(() => refresh(data.id)); return; } case "recovered": { - postStream.triggerRecoveredPost(data.id, data.post_number); + postStream.triggerRecoveredPost(data.id, data.post_number).then(() => refresh(data.id)); return; } case "created": { - postStream.triggerNewPostInStream(data.id); - if (self.get('currentUser.id') !== data.user_id) { + postStream.triggerNewPostInStream(data.id).then(() => refresh()); + if (this.get('currentUser.id') !== data.user_id) { Discourse.notifyBackgroundCountIncrement(); } return; @@ -673,23 +718,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, - // If our current post is changed, notify the router - _currentPostChanged: function() { - const currentPost = this.get('model.currentPost'); - if (currentPost) { - this.send('postChangedRoute', currentPost); - } - }.observes('model.currentPost'), - readPosts(topicId, postNumbers) { - const topic = this.get("model"), - postStream = topic.get("postStream"); + const topic = this.get("model"); + const postStream = topic.get("postStream"); + + if (topic.get('id') === topicId) { - if (topic.get("id") === topicId) { // TODO identity map for postNumber - _.each(postStream.get('posts'), post => { - if (_.include(postNumbers, post.post_number) && !post.read) { - post.set("read", true); + postStream.get('posts').forEach(post => { + if (!post.read && postNumbers.indexOf(post.post_number) !== -1) { + post.set('read', true); + this.appEvents.trigger('post-stream:refresh', { id: post.id }); } }); @@ -709,59 +748,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, - // Called the the topmost visible post on the page changes. - topVisibleChanged(post) { - if (!post) { return; } - - const postStream = this.get('model.postStream'); - const firstLoadedPost = postStream.get('posts.firstObject'); - - this.set('model.currentPost', post.get('post_number')); - - if (post.get('post_number') === 1) { return; } - - if (firstLoadedPost && firstLoadedPost === post) { - // Note: jQuery shouldn't be done in a controller, but how else can we - // trigger a scroll after a promise resolves in a controller? We need - // to do this to preserve upwards infinte scrolling. - const $body = $('body'); - const elemId = `#post_${post.get('post_number')}`; - const $elem = $(elemId).closest('.post-cloak'); - const elemPos = $elem.position(); - const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0; - - postStream.prependMore().then(function() { - Em.run.next(function () { - const $refreshedElem = $(elemId).closest('.post-cloak'); - - // Quickly going back might mean the element is destroyed - const position = $refreshedElem.position(); - if (position && position.top) { - $('html, body').scrollTop(position.top + distToElement); - } - }); - }); - } - }, - - /** - Called the the bottommost visible post on the page changes. - - @method bottomVisibleChanged - @params {Discourse.Post} post that is at the bottom - **/ - bottomVisibleChanged(post) { - if (!post) { return; } - - const postStream = this.get('model.postStream'); - const lastLoadedPost = postStream.get('posts.lastObject'); - - this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post)); - - if (lastLoadedPost && lastLoadedPost === post) { - postStream.appendMore(); - } - }, _showFooter: function() { const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts"); diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6 index c67125cdb2c..a78407f225f 100644 --- a/app/assets/javascripts/discourse/controllers/user-card.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-card.js.es6 @@ -45,7 +45,7 @@ export default Ember.Controller.extend({ } // Don't show on mobile - if (Discourse.Mobile.mobileView) { + if (this.site.mobileView) { const url = "/users/" + username; DiscourseURL.routeTo(url); return; diff --git a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 index 73c8ff293a9..06a1be30db3 100644 --- a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 @@ -2,7 +2,6 @@ import computed from 'ember-addons/ember-computed-decorators'; import Topic from 'discourse/models/topic'; export default Ember.Controller.extend({ - needs: ["application", "user-topics-list", "user"], pmView: false, viewingSelf: Em.computed.alias('controllers.user.viewingSelf'), @@ -11,10 +10,6 @@ export default Ember.Controller.extend({ selected: Em.computed.alias('controllers.user-topics-list.selected'), bulkSelectEnabled: Em.computed.alias('controllers.user-topics-list.bulkSelectEnabled'), - mobileView: function() { - return Discourse.Mobile.mobileView; - }.property(), - showNewPM: function(){ return this.get('controllers.user.viewingSelf') && Discourse.User.currentProp('can_send_private_messages'); diff --git a/app/assets/javascripts/discourse/ember/resolver.js.es6 b/app/assets/javascripts/discourse/ember/resolver.js.es6 index c8c2a3eeea2..d7124c0900f 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); }, @@ -139,7 +141,7 @@ export default Ember.DefaultResolver.extend({ }, findMobileTemplate(parsedName) { - if (Discourse.Mobile.mobileView) { + if (this.mobileView) { var mobileParsedName = this.parseName(parsedName.fullName.replace("template:", "template:mobile/")); return this.findTemplate(mobileParsedName); } 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 00000000000..c5c67a64e02 --- /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 456f876054f..fa35b02d9b5 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 00000000000..474171ee591 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/node.js.es6 @@ -0,0 +1,31 @@ +import { h } from 'virtual-dom'; +import { relativeAge, longDate } from 'discourse/lib/formatter'; +import { number } from 'discourse/lib/formatter'; + +export function dateNode(dt) { + if (typeof dt === "string") { dt = new Date(dt); } + if (dt) { + const attributes = { + title: longDate(dt), + 'data-time': dt.getTime(), + 'data-format': 'tiny' + }; + + return h('span.relative-date', { attributes }, relativeAge(dt)); + } +} + +export function numberNode(num, opts) { + opts = opts || {}; + num = parseInt(num, 10); + if (isNaN(num)) { num = 0; } + + const numString = num.toString(); + const attributes = { }; + const formatted = number(num); + if (formatted !== numString) { + attributes.title = numString; + } + + return h('span.number', { className: opts.className, attributes }, formatted); +} diff --git a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 index 1d627539884..d358a1e1d56 100644 --- a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 +++ b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 @@ -1,4 +1,4 @@ -import { onToolbarCreate } from 'discourse/components/d-editor'; +import { withPluginApi } from 'discourse/lib/plugin-api'; export default { name: 'enable-emoji', @@ -7,13 +7,15 @@ export default { const siteSettings = container.lookup('site-settings:main'); if (siteSettings.enable_emoji) { - onToolbarCreate(toolbar => { - toolbar.addButton({ - id: 'emoji', - group: 'extras', - icon: 'smile-o', - action: 'emoji', - title: 'composer.emoji' + withPluginApi('0.1', api => { + api.onToolbarCreate(toolbar => { + toolbar.addButton({ + id: 'emoji', + group: 'extras', + icon: 'smile-o', + action: 'emoji', + title: 'composer.emoji' + }); }); }); diff --git a/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 b/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 index 746abc38f48..99486215aa8 100644 --- a/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 +++ b/app/assets/javascripts/discourse/initializers/ensure-max-image-dimensions.js.es6 @@ -1,7 +1,7 @@ export default { name: 'ensure-image-dimensions', after: 'mobile', - initialize: function() { + initialize(container) { if (!window) { return; } // This enforces maximum dimensions of images based on site settings @@ -11,7 +11,8 @@ export default { var width = Discourse.SiteSettings.max_image_width; var height = Discourse.SiteSettings.max_image_height; - if (Discourse.Mobile.mobileView) { + const site = container.lookup('site:main'); + if (site.mobileView) { width = $(window).width() - 20; } diff --git a/app/assets/javascripts/discourse/initializers/mobile.js.es6 b/app/assets/javascripts/discourse/initializers/mobile.js.es6 index d39e0802570..d4bffb3569b 100644 --- a/app/assets/javascripts/discourse/initializers/mobile.js.es6 +++ b/app/assets/javascripts/discourse/initializers/mobile.js.es6 @@ -1,14 +1,19 @@ -/** - Initializes the `Discourse.Mobile` helper object. -**/ +import Mobile from 'discourse/lib/mobile'; + +// Initializes the `Mobile` helper object. export default { name: 'mobile', after: 'inject-objects', - initialize: function(container) { - Discourse.Mobile.init(); - var site = container.lookup('site:main'); - site.set('mobileView', Discourse.Mobile.mobileView); + initialize(container, app) { + Mobile.init(); + const site = container.lookup('site:main'); + + site.set('mobileView', Mobile.mobileView); + site.set('isMobileDevice', Mobile.isMobileDevice); + + // This is a bit weird but you can't seem to inject into the resolver? + app.registry.resolver.__resolver__.mobileView = Mobile.mobileView; } }; diff --git a/app/assets/javascripts/discourse/initializers/page-tracking.js.es6 b/app/assets/javascripts/discourse/initializers/page-tracking.js.es6 index 2511dd3ebac..f458900a087 100644 --- a/app/assets/javascripts/discourse/initializers/page-tracking.js.es6 +++ b/app/assets/javascripts/discourse/initializers/page-tracking.js.es6 @@ -1,5 +1,5 @@ import { cleanDOM } from 'discourse/routes/discourse'; -import PageTracker from 'discourse/lib/page-tracker'; +import { startPageTracking, onPageChange } from 'discourse/lib/page-tracker'; export default { name: "page-tracking", @@ -34,13 +34,12 @@ export default { } }; - const pageTracker = PageTracker.current(); - pageTracker.start(); + startPageTracking(router); // Out of the box, Discourse tries to track google analytics // if it is present if (typeof window._gaq !== 'undefined') { - pageTracker.on('change', function(url, title) { + onPageChange((url, title) => { window._gaq.push(["_set", "title", title]); window._gaq.push(['_trackPageview', url]); }); @@ -49,7 +48,7 @@ export default { // Also use Universal Analytics if it is present if (typeof window.ga !== 'undefined') { - pageTracker.on('change', function(url, title) { + onPageChange.on('change', (url, title) => { window.ga('send', 'pageview', {page: url, title: title}); }); } diff --git a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 index 5d82473b89a..df6e8d8bc27 100644 --- a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 +++ b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 @@ -1,11 +1,13 @@ -import { decorateCooked } from 'discourse/lib/plugin-api'; -import HighlightSyntax from 'discourse/lib/highlight-syntax'; -import Lightbox from 'discourse/lib/lightbox'; +import highlightSyntax from 'discourse/lib/highlight-syntax'; +import lightbox from 'discourse/lib/lightbox'; +import { withPluginApi } from 'discourse/lib/plugin-api'; export default { name: "post-decorations", - initialize: function(container) { - decorateCooked(container, HighlightSyntax); - decorateCooked(container, Lightbox); + initialize() { + withPluginApi('0.1', api => { + api.decorateCooked(highlightSyntax); + api.decorateCooked(lightbox); + }); } }; diff --git a/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 b/app/assets/javascripts/discourse/initializers/signup-cta.js.es6 index fad9e45634f..4074d293f2f 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/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index fd820d82021..63ef5104b4d 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -96,7 +96,7 @@ export default { }); if (!Ember.testing) { - if (!Discourse.Mobile.mobileView) { + if (!site.mobileView) { bus.subscribe("/notification-alert/" + user.get('id'), function(data){ onNotification(data, user); }); diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index e29699ce1c3..886a5918053 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -3,7 +3,6 @@ @module $.fn.autocomplete **/ - export var CANCELLED_STATUS = "__CANCELLED"; const allowedLettersRegex = /[\s\t\[\{\(\/]/; @@ -232,7 +231,7 @@ export default function(options) { vOffset = div.height(); } - if (Discourse.Mobile.mobileView && !isInput) { + if (Discourse.Site.currentProp('mobileView') && !isInput) { div.css('width', 'auto'); if ((me.height() / 2) >= pos.top) { vOffset = -23; } diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 index d20a1c0109d..de1d6afc338 100644 --- a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -1,6 +1,6 @@ import DiscourseURL from 'discourse/lib/url'; -import PageTracker from 'discourse/lib/page-tracker'; import KeyValueStore from 'discourse/lib/key-value-store'; +import { onPageChange } from 'discourse/lib/page-tracker'; let primaryTab = false; let liveEnabled = false; @@ -84,7 +84,8 @@ function setupNotifications() { if (document) { document.addEventListener("scroll", resetIdle); } - PageTracker.on("change", resetIdle); + + onPageChange(resetIdle); } function resetIdle() { diff --git a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 index a126bf783a8..43b74ab1efa 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 */ @@ -33,7 +31,12 @@ const DiscourseLocation = Ember.Object.extend({ @method initState */ initState() { - set(this, 'history', get(this, 'history') || window.history); + const history = get(this, 'history') || window.history; + if (history && history.scrollRestoration) { + history.scrollRestoration = "manual"; + } + + set(this, 'history', history); let url = this.formatURL(this.getURL()); const loc = get(this, 'location'); @@ -221,36 +224,4 @@ const DiscourseLocation = Ember.Object.extend({ }); -/** - Since we're using pushState/replaceState let's add extra hooks to cloakedView to - eject itself when the popState occurs. This results in better back button - behavior. -**/ -CloakedCollectionView.reopen({ - _watchForPopState: function() { - const self = this, - cb = function() { - // Sam: This is a hack, but a very important one - // Due to the way we use replace state the back button works strangely - // - // If you visit a topic from the front page, scroll a bit around and then hit back - // you notice that first the page scrolls a bit (within the topic) and then it goes back - // this transition is jarring and adds uneeded rendering costs. - // - // To repro comment the hack out and wack a debugger statement here and in - // topic_route deactivate - $('.posts,#topic-title').hide(); - self.cleanUp(); - self.set('controller.model.postStream.loaded', false); - }; - this.set('_callback', cb); - popstateCallbacks.addObject(cb); - }.on('didInsertElement'), - - _disbandWatcher: function() { - popstateCallbacks.removeObject(this.get('_callback')); - this.set('_callback', null); - }.on('willDestroyElement') -}); - export default DiscourseLocation; diff --git a/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 b/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 index 7df7dd75cf2..47d77040dde 100644 --- a/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 +++ b/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 @@ -161,7 +161,7 @@ function showSelector(options) { options.appendTo.append('
'); $('.emoji-modal-wrapper').click(() => closeSelector()); - if (Discourse.Mobile.mobileView) PER_ROW = 9; + if (Discourse.Site.currentProp('mobileView')) { PER_ROW = 9; } const page = keyValueStore.getInt("emojiPage", 0); const offset = keyValueStore.getInt("emojiOffset", 0); diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index 45e888395f5..f67a1e682bb 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -28,7 +28,7 @@ const bindings = { 'home': {handler: 'goToFirstPost', anonymous: true}, 'j': {handler: 'selectDown', anonymous: true}, 'k': {handler: 'selectUp', anonymous: true}, - 'l': {click: '.topic-post.selected button[data-action="like"]'}, + 'l': {click: '.topic-post.selected button.toggle-like'}, 'm m': {click: 'div.notification-options li[data-id="0"] a'}, // mark topic as muted 'm r': {click: 'div.notification-options li[data-id="1"] a'}, // mark topic as regular 'm t': {click: 'div.notification-options li[data-id="2"] a'}, // mark topic as tracking @@ -222,10 +222,14 @@ export default { // TODO: We should keep track of the post without a CSS class const selectedPostId = parseInt($('.topic-post.selected article.boxed').data('post-id'), 10); if (selectedPostId) { - const topicController = container.lookup('controller:topic'), - post = topicController.get('model.postStream.posts').findBy('id', selectedPostId); + const topicController = container.lookup('controller:topic'); + const post = topicController.get('model.postStream.posts').findBy('id', selectedPostId); if (post) { - topicController.send(action, post); + // TODO: Use ember closure actions + const result = topicController._actions[action].call(topicController, post); + if (result && result.then) { + this.appEvents.trigger('post-stream:refresh', { id: selectedPostId }); + } } } }, @@ -312,12 +316,7 @@ export default { } if ($article.is('.topic-post')) { - let tabLoc = $article.find('a.tabLoc'); - if (tabLoc.length === 0) { - tabLoc = $(''); - $article.prepend(tabLoc); - } - tabLoc.focus(); + $('a.tabLoc', $article).focus(); } this._scrollList($article, direction); diff --git a/app/assets/javascripts/discourse/lib/mobile.js b/app/assets/javascripts/discourse/lib/mobile.js.es6 similarity index 78% rename from app/assets/javascripts/discourse/lib/mobile.js rename to app/assets/javascripts/discourse/lib/mobile.js.es6 index 552e3f68733..561a05458ad 100644 --- a/app/assets/javascripts/discourse/lib/mobile.js +++ b/app/assets/javascripts/discourse/lib/mobile.js.es6 @@ -1,10 +1,10 @@ // An object that is responsible for logic related to mobile devices. -Discourse.Mobile = { +const Mobile = { isMobileDevice: false, mobileView: false, - init: function() { - var $html = $('html'); + init() { + const $html = $('html'); this.isMobileDevice = $html.hasClass('mobile-device'); this.mobileView = $html.hasClass('mobile-view'); @@ -42,3 +42,13 @@ Discourse.Mobile = { window.location.assign(window.location.pathname + '?mobile_view=' + (mobile ? '1' : '0')); } }; + +// Backwards compatibiltity, deprecated +Object.defineProperty(Discourse, 'Mobile', { + get: function() { + Ember.warn("DEPRECATION: `Discourse.Mobile` is deprecated, use `this.site.mobileView` instead"); + return Mobile; + } +}); + +export default Mobile; diff --git a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 index 351c78211ed..7ef49363b9d 100644 --- a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 +++ b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 @@ -1,37 +1,31 @@ -import Singleton from 'discourse/mixins/singleton'; +const PageTracker = Ember.Object.extend(Ember.Evented); +let _pageTracker = PageTracker.create(); -/** - Called whenever the "page" changes. This allows us to set up analytics - and other tracking. +let _started = false; +export function startPageTracking(router) { + if (_started) { return; } - To get notified when the page changes, you can install a hook like so: + router.on('didTransition', function() { + this.send('refreshTitle'); + const url = Discourse.getURL(this.get('url')); - ```javascript - PageTracker.current().on('change', function(url, title) { - console.log('the page changed to: ' + url + ' and title ' + title); + // Refreshing the title is debounced, so we need to trigger this in the + // next runloop to have the correct title. + Em.run.next(() => { + _pageTracker.trigger('change', url, Discourse.get('_docTitle')); }); - ``` -**/ -const PageTracker = Ember.Object.extend(Ember.Evented, { - start: function() { - if (this.get('started')) { return; } + }); + _started = true; +} - var router = Discourse.__container__.lookup('router:main'), - self = this; +export function onPageChange(fn) { + _pageTracker.on('change', fn); +} - router.on('didTransition', function() { - this.send('refreshTitle'); - var url = Discourse.getURL(this.get('url')); - - // Refreshing the title is debounced, so we need to trigger this in the - // next runloop to have the correct title. - Em.run.next(function() { - self.trigger('change', url, Discourse.get('_docTitle')); - }); - }); - this.set('started', true); +// backwards compatibility +export default { + current() { + console.warn(`Using PageTracker.current() is deprecated. Your plugin should use the PluginAPI`); + return _pageTracker; } -}); -PageTracker.reopenClass(Singleton); - -export default PageTracker; +}; diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 5a700db5f2a..1e74ae39743 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -1,4 +1,275 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { addDecorator } from 'discourse/widgets/post-cooked'; import ComposerEditor from 'discourse/components/composer-editor'; +import { addButton } from 'discourse/widgets/post-menu'; +import { includeAttributes } from 'discourse/lib/transform-post'; +import { addToolbarCallback } from 'discourse/components/d-editor'; +import { addWidgetCleanCallback } from 'discourse/components/mount-widget'; +import { decorateWidget } from 'discourse/widgets/widget'; +import { onPageChange } from 'discourse/lib/page-tracker'; + +class PluginApi { + constructor(version, container) { + this.version = version; + this.container = container; + this._currentUser = container.lookup('current-user:main'); + } + + /** + * Use this function to retrieve the currently logged in user within your plugin. + * If the user is not logged in, it will be `null`. + **/ + getCurrentUser() { + return this._currentUser; + } + + /** + * Used for decorating the `cooked` content of a post after it is rendered using + * jQuery. + * + * `callback` will be called when it is time to decorate with a jQuery selector. + * + * Use `options.onlyStream` if you only want to decorate posts within a topic, + * and not in other places like the user stream. + * + * For example, to add a yellow background to all posts you could do this: + * + * ``` + * api.decorateCooked($elem => $elem.css({ backgroundColor: 'yellow' })); + * ``` + **/ + decorateCooked(callback, opts) { + opts = opts || {}; + + addDecorator(callback); + + if (!opts.onlyStream) { + decorate(ComposerEditor, 'previewRefreshed', callback); + decorate(this.container.lookupFactory('view:user-stream'), 'didInsertElement', callback); + } + } + + /** + * addPosterIcon(callback) + * + * This function can be used to add an icon with a link that will be displayed + * beside a poster's name. The `callback` is called with the post's user custom + * fields and post attrions. An icon will be rendered if the callback returns + * an object with the appropriate attributes. + * + * The returned object can have the following attributes: + * + * icon the font awesome icon to render + * emoji an emoji icon to render + * className (optional) a css class to apply to the icon + * url (optional) where to link the icon + * title (optional) the tooltip title for the icon on hover + * + * ``` + * api.addPosterIcon((cfs, attrs) => { + * if (cfs.customer) { + * return { icon: 'user', className: 'customer', title: 'customer' }; + * } + * }); + * ``` + **/ + addPosterIcon(cb) { + decorateWidget('poster-name:after', dec => { + const attrs = dec.attrs; + + const result = cb(attrs.userCustomFields || {}, attrs); + if (result) { + let iconBody; + + if (result.icon) { + iconBody = iconNode(result.icon); + } else if (result.emoji) { + iconBody = result.emoji.split('|').map(emoji => { + const src = Discourse.Emoji.urlFor(emoji); + return dec.h('img', { className: 'emoji', attributes: { src } }); + }); + } + + if (result.text) { + iconBody = [iconBody, result.text]; + } + + if (result.url) { + iconBody = dec.h('a', { attributes: { href: result.url } }, iconBody); + } + + + return dec.h('span', + { className: result.className, attributes: { title: result.title } }, + iconBody); + } + }); + } + + /** + * The main interface for extending widgets with additional HTML. + * + * The `name` you pass it should be the name of the widget and a type + * for the decorator. All widgets support `before` and `after` types. + * + * Example: + * + * ``` + * api.decorateWidget('post:after', () => { + * return "I am displayed after every post!"; + * }); + * ``` + * + * Your decorator will be called with an instance of a `DecoratorHelper` + * object, which provides methods you can use to build more interesting + * formatting. + * + * ``` + * api.decorateWidget('post:after', helper => { + * return helper.h('p.fancy', `I'm an HTML paragraph on post with id ${helper.attrs.id}`); + * }); + * + * (View the source for `DecoratorHelper` for more helper methods you + * can use in your plugin decorators.) + * + **/ + decorateWidget(name, fn) { + decorateWidget(name, fn); + } + + /** + * Adds a new action to a widget that already exists. You can use this to + * add additional functionality from your plugin. + * + * Example: + * + * ``` + * api.attachWidgetAction('post', 'annoyMe', () => { + * alert('ANNOYED!'); + * }); + * ``` + **/ + attachWidgetAction(widget, actionName, fn) { + const widgetClass = this.container.lookupFactory(`widget:${widget}`); + widgetClass.prototype[actionName] = fn; + } + + /** + * Add more attributes to the Post's `attrs` object passed through to widgets. + * You'll need to do this if you've added attributes to the serializer for a + * Post and want to use them when you're rendering. + * + * Example: + * + * ``` + * // attrs.poster_age and attrs.poster_height will be present + * api.includePostAttributes('poster_age', 'poster_height'); + * ``` + * + **/ + includePostAttributes(...attributes) { + includeAttributes(...attributes); + } + + /** + * Add a new button below a post with your plugin. + * + * The `callback` function will be called whenever the post menu is rendered, + * and if you return an object with the button details it will be rendered. + * + * Example: + * + * ``` + * api.addPostMenuButton('coffee', () => { + * return { + * action: 'drinkCoffee', + * icon: 'coffee', + * className: 'hot-coffee', + * title: 'coffee.title', + * position: 'first' // can be `first`, `last` or `second-last-hidden` + * }; + * }); + **/ + addPostMenuButton(name, callback) { + addButton(name, callback); + } + + /** + * A hook that is called when the editor toolbar is created. You can + * use this to add custom editor buttons. + * + * Example: + * + * ``` + * api.onToolbarCreate(toolbar => { + * toolbar.addButton({ + * id: 'pop-text', + * group: 'extras', + * icon: 'bolt', + * action: 'makeItPop', + * title: 'pop_format.title' + * }); + * }); + **/ + onToolbarCreate(callback) { + addToolbarCallback(callback); + } + + /** + * A hook that is called when the post stream is removed from the DOM. + * This advanced hook should be used if you end up wiring up any + * events that need to be torn down when the user leaves the topic + * page. + **/ + cleanupStream(fn) { + addWidgetCleanCallback('post-stream', fn); + } + + /** + Called whenever the "page" changes. This allows us to set up analytics + and other tracking. + + To get notified when the page changes, you can install a hook like so: + + ```javascript + api.onPageChange((url, title) => { + console.log('the page changed to: ' + url + ' and title ' + title); + }); + ``` + **/ + onPageChange(fn) { + onPageChange(fn); + } + +} + +let _pluginv01; +function getPluginApi(version) { + if (version === "0.1") { + if (!_pluginv01) { + _pluginv01 = new PluginApi(version, Discourse.__container__); + } + return _pluginv01; + } else { + console.warn(`Plugin API v${version} is not supported`); + } +} + +/** + * withPluginApi(version, apiCode, noApi) + * + * Helper to version our client side plugin API. Pass the version of the API that your + * plugin is coded against. If that API is available, the `apiCodeCallback` function will + * be called with the `PluginApi` object. +*/ +export function withPluginApi(version, apiCodeCallback, opts) { + opts = opts || {}; + + const api = getPluginApi(version); + if (api) { + return apiCodeCallback(api); + } +} let _decorateId = 0; function decorate(klass, evt, cb) { @@ -7,38 +278,6 @@ function decorate(klass, evt, cb) { klass.reopen(mixin); } -export function decorateCooked(container, cb) { - const postView = container.lookupFactory('view:post'); - decorate(postView, 'postViewInserted', cb); - decorate(postView, 'postViewUpdated', cb); - decorate(ComposerEditor, 'previewRefreshed', cb); - decorate(container.lookupFactory('view:embedded-post'), 'didInsertElement', cb); - decorate(container.lookupFactory('view:user-stream'), 'didInsertElement', cb); -} - -// This is backported so plugins in the new format will not raise errors -// -// To upgrade your plugin for backwards compatibility, you can add code in this -// form: -// -// function newApiCode(api) { -// // api.xyz(); -// } -// -// function oldCode() { -// // your pre-PluginAPI code goes here. You will be able to delete this -// // code once the `PluginAPI` has been rolled out to all versions of -// // Discourse you want to support. -// } -// -// // `newApiCode` will use API version 0.1, if no API support then -// // `oldCode` will be called -// withPluginApi('0.1', newApiCode, { noApi: oldCode }); -// -export function withPluginApi(version, apiCodeCallback, opts) { - console.warn(`Plugin API v${version} is not supported`); - - if (opts && opts.noApi) { - return opts.noApi(); - } +export function decorateCooked() { + console.warn('`decorateCooked` has been removed. Use `getPluginApi(version).decorateCooked` instead'); } 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 db7f1238a72..a57f46c9eb4 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, @@ -34,6 +36,11 @@ export default Ember.Object.extend(Ember.Array, { this._changeArray(cb, this.get('posts.length') - 1, 1, 0); }, + refreshAll(cb) { + const length = this.get('posts.length'); + this._changeArray(cb, 0, length, length); + }, + appending(postIds) { this._changeArray(() => { const appendingIds = this._appendingIds; diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 93e584f2395..3506b06d6b9 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 2bc93738703..60704ac4bbf 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 00000000000..5eed4be23b8 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -0,0 +1,201 @@ +function actionDescription(action, acted, count) { + if (acted) { + if (count <= 1) { + return I18n.t(`post.actions.by_you.${action}`); + } else { + return I18n.t(`post.actions.by_you_and_others.${action}`, { count: count - 1 }); + } + } else { + return I18n.t(`post.actions.by_others.${action}`, { count }); + } +} + +const _additionalAttributes = []; + +export function includeAttributes(...attributes) { + attributes.forEach(a => _additionalAttributes.push(a)); +} + +export function transformBasicPost(post) { + // Note: it can be dangerous to not use `get` in Ember code, but this is significantly + // faster and has tests to confirm it works. We only call `get` when the property is a CP + return { + id: post.id, + hidden: post.hidden, + deleted: post.get('deleted'), + deleted_at: post.deleted_at, + user_deleted: post.user_deleted, + isDeleted: post.deleted_at || post.user_deleted, + deletedByAvatarTemplate: null, + deletedByUsername: null, + primary_group_name: post.primary_group_name, + wiki: post.wiki, + firstPost: post.post_number === 1, + post_number: post.post_number, + cooked: post.cooked, + via_email: post.via_email, + user_id: post.user_id, + usernameUrl: Discourse.getURL(`/users/${post.username}`), + username: post.username, + avatar_template: post.avatar_template, + bookmarked: post.bookmarked, + yours: post.yours, + shareUrl: post.get('shareUrl'), + staff: post.staff, + admin: post.admin, + moderator: post.moderator, + new_user: post.trust_level === 0, + name: post.name, + user_title: post.user_title, + created_at: post.created_at, + updated_at: post.updated_at, + canDelete: post.can_delete, + canRecover: post.can_recover, + canEdit: post.can_edit, + canFlag: !Ember.isEmpty(post.get('flagsAvailable')), + version: post.version, + canRecoverTopic: false, + canDeletedTopic: false, + canViewEditHistory: post.can_view_edit_history, + canWiki: post.can_wiki, + showLike: false, + liked: false, + canToggleLike: false, + likeCount: false, + actionsSummary: null, + read: post.read, + replyToUsername: null, + replyToAvatarTemplate: null, + reply_to_post_number: post.reply_to_post_number, + cooked_hidden: !!post.cooked_hidden, + expandablePost: false, + replyCount: post.reply_count, + }; + +} + + +export default function transformPost(currentUser, site, post, prevPost, nextPost) { + // Note: it can be dangerous to not use `get` in Ember code, but this is significantly + // faster and has tests to confirm it works. We only call `get` when the property is a CP + const postType = post.post_type; + const postTypes = site.post_types; + const topic = post.topic; + const details = topic.get('details'); + + const postAtts = transformBasicPost(post); + + postAtts.topicId = topic.id; + postAtts.topicOwner = details.created_by.id === post.user_id; + postAtts.post_type = postType; + postAtts.via_email = post.via_email; + postAtts.isModeratorAction = postType === postTypes.moderator_action; + postAtts.isWhisper = postType === postTypes.whisper; + postAtts.isSmallAction = postType === postTypes.small_action; + postAtts.canBookmark = !!currentUser; + postAtts.canManage = currentUser && currentUser.get('canManageTopic'); + postAtts.canViewRawEmail = currentUser && (currentUser.id === post.user_id || currentUser.staff); + postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic; + postAtts.isWarning = topic.is_warning; + postAtts.links = post.get('internalLinks'); + postAtts.replyDirectlyBelow = nextPost && nextPost.reply_to_post_number === post.post_number; + postAtts.replyDirectlyAbove = prevPost && post.reply_to_post_number === prevPost.post_number; + postAtts.linkCounts = post.link_counts; + postAtts.actionCode = post.action_code; + postAtts.actionCodeWho = post.action_code_who; + postAtts.userCustomFields = post.user_custom_fields; + + const showPMMap = topic.archetype === 'private_message' && post.post_number === 1; + if (showPMMap) { + postAtts.showPMMap = true; + postAtts.allowedGroups = details.allowed_groups; + postAtts.allowedUsers = details.allowed_users; + postAtts.canRemoveAllowedUsers = details.can_remove_allowed_users; + postAtts.canInvite = details.can_invite_to; + } + + const showTopicMap = showPMMap || (post.post_number === 1 && topic.archetype === 'regular' && topic.posts_count > 1); + if (showTopicMap) { + postAtts.showTopicMap = true; + postAtts.topicUrl = topic.get('url'); + postAtts.topicCreatedAt = topic.created_at; + postAtts.createdByUsername = details.created_by.username; + postAtts.createdByAvatarTemplate = details.created_by.avatar_template; + + postAtts.lastPostUrl = topic.get('lastPostUrl'); + postAtts.lastPostUsername = details.last_poster.username; + postAtts.lastPostAvatarTemplate = details.last_poster.avatar_template; + postAtts.lastPostAt = topic.last_posted_at; + + postAtts.topicReplyCount = topic.get('replyCount'); + postAtts.topicViews = topic.views; + postAtts.topicViewsHeat = topic.get('viewsHeat'); + + postAtts.participantCount = topic.participant_count; + postAtts.topicLikeCount = topic.like_count; + postAtts.topicLinks = details.links; + if (postAtts.topicLinks) { + postAtts.topicLinkLength = details.links.length; + } + postAtts.topicPostsCount = topic.posts_count; + + postAtts.participants = details.participants; + + const postStream = topic.get('postStream'); + postAtts.userFilters = postStream.userFilters; + postAtts.topicSummaryEnabled = postStream.summary; + postAtts.topicWordCount = topic.word_count; + postAtts.hasTopicSummary = topic.has_summary; + } + + if (postAtts.isDeleted) { + postAtts.deletedByAvatarTemplate = post.get('postDeletedBy.avatar_template'); + postAtts.deletedByUsername = post.get('postDeletedBy.username'); + } + + const replyToUser = post.get('reply_to_user'); + if (replyToUser) { + postAtts.replyToUsername = replyToUser.username; + postAtts.replyToAvatarTemplate = replyToUser.avatar_template; + } + + if (post.actions_summary) { + postAtts.actionsSummary = post.actions_summary.filter(a => { + return a.actionType.name_key !== 'like' && a.count > 0; + }).map(a => { + const acted = a.acted; + const action = a.actionType.name_key; + const count = a.count; + + return { id: a.id, + postId: post.id, + action, + acted, + count, + canUndo: a.can_undo, + canDeferFlags: a.can_defer_flags, + description: actionDescription(action, acted, count) }; + }); + } + + const likeAction = post.likeAction; + if (likeAction) { + postAtts.liked = likeAction.acted; + postAtts.canToggleLike = likeAction.get('canToggle'); + postAtts.showLike = postAtts.liked || postAtts.canToggleLike; + postAtts.likeCount = likeAction.count; + } + + if (postAtts.post_number === 1) { + postAtts.canRecoverTopic = topic.deleted_at && details.can_recover; + postAtts.canDeleteTopic = !topic.deleted_at && details.can_delete; + postAtts.expandablePost = topic.expandable_first_post; + } else { + postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover; + postAtts.canDelete = !postAtts.isDeleted && postAtts.canDelete; + } + + _additionalAttributes.forEach(a => postAtts[a] = post[a]); + + return postAtts; +} diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index c35cffba304..3c76d4f5144 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 a8f75cd5376..a39d78fb501 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/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index cc094dc0846..9107fe62f1b 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -154,7 +154,7 @@ const Composer = RestModel.extend({ usernameLink }); - if (!Discourse.Mobile.mobileView) { + if (!this.site.mobileView) { const replyUsername = post.get('reply_to_user.username'); const replyAvatarTemplate = post.get('reply_to_user.avatar_template'); if (replyUsername && replyAvatarTemplate && this.get('action') === EDIT) { diff --git a/app/assets/javascripts/discourse/models/nav-item.js.es6 b/app/assets/javascripts/discourse/models/nav-item.js.es6 index 1d614c0d1d0..7eb1616485b 100644 --- a/app/assets/javascripts/discourse/models/nav-item.js.es6 +++ b/app/assets/javascripts/discourse/models/nav-item.js.es6 @@ -7,7 +7,7 @@ const NavItem = Discourse.Model.extend({ name = this.get('name'), count = this.get('count') || 0; - if (name === 'latest' && !Discourse.Mobile.mobileView) { + if (name === 'latest' && !Discourse.Site.currentProp('mobileView')) { count = 0; } diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 02511e01f09..f9118bf4a08 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, @@ -295,6 +280,7 @@ export default RestModel.extend({ if (idx !== -1) { stream.pushObjects(gap); return this.appendMore().then(() => { + delete this.get('gaps.after')[postId]; this.get('stream').enumerableContentDidChange(); }); } @@ -377,7 +363,6 @@ export default RestModel.extend({ // Commit the post we staged. Call this after a save succeeds. commitPost(post) { - if (this.get('topic.id') === post.get('topic_id')) { if (this.get('loadedAllPosts')) { this.appendPost(post); @@ -414,7 +399,6 @@ export default RestModel.extend({ const stored = this.storePost(post); if (stored) { const posts = this.get('posts'); - calcDayDiff(posts.get('firstObject'), stored); posts.unshiftObject(stored); } @@ -426,7 +410,6 @@ export default RestModel.extend({ if (stored) { const posts = this.get('posts'); - calcDayDiff(stored, this.get('lastAppended')); if (!posts.contains(stored)) { if (!this.get('loadingBelow')) { this.get('postsWithPlaceholders').appendPost(() => posts.pushObject(stored)); @@ -445,12 +428,15 @@ export default RestModel.extend({ removePosts(posts) { if (Ember.isEmpty(posts)) { return; } - const postIds = posts.map(p => p.get('id')); - const identityMap = this._identityMap; + this.get('postsWithPlaceholders').refreshAll(() => { + const allPosts = this.get('posts'); + const postIds = posts.map(p => p.get('id')); + const identityMap = this._identityMap; - this.get('stream').removeObjects(postIds); - this.get('posts').removeObjects(posts); - postIds.forEach(id => delete identityMap[id]); + this.get('stream').removeObjects(postIds); + allPosts.removeObjects(posts); + postIds.forEach(id => delete identityMap[id]); + }); }, // Returns a post from the identity map if it's been inserted. @@ -471,10 +457,12 @@ export default RestModel.extend({ have no filters. **/ triggerNewPostInStream(postId) { - if (!postId) { return; } + const resolved = Ember.RSVP.Promise.resolve(); + + if (!postId) { return resolved; } // We only trigger if there are no filters active - if (!this.get('hasNoFilters')) { return; } + if (!this.get('hasNoFilters')) { return resolved; } const loadedAllPosts = this.get('loadedAllPosts'); @@ -482,25 +470,27 @@ export default RestModel.extend({ this.get('stream').addObject(postId); if (loadedAllPosts) { this.set('loadingLastPost', true); - this.findPostsByIds([postId]).then(posts => { + return this.findPostsByIds([postId]).then(posts => { posts.forEach(p => this.appendPost(p)); }).finally(() => { this.set('loadingLastPost', false); }); } } + + return resolved; }, triggerRecoveredPost(postId) { const existing = this._identityMap[postId]; if (existing) { - this.triggerChangedPost(postId, new Date()); + return this.triggerChangedPost(postId, new Date()); } else { // need to insert into stream const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then(p => { + return Discourse.ajax(url).then(p => { const post = store.createRecord('post', p); const stream = this.get("stream"); const posts = this.get("posts"); @@ -541,34 +531,26 @@ export default RestModel.extend({ const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then(p => { + return Discourse.ajax(url).then(p => { this.storePost(store.createRecord('post', p)); }).catch(() => { this.removePosts([existing]); }); } + return Ember.RSVP.Promise.resolve(); }, triggerChangedPost(postId, updatedAt) { - if (!postId) { return; } + const resolved = Ember.RSVP.Promise.resolve(); + if (!postId) { return resolved; } const existing = this._identityMap[postId]; if (existing && existing.updated_at !== updatedAt) { const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p))); + return Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p))); } - }, - - // Returns the "thread" of posts in the history of a post. - findReplyHistory(post) { - const url = `/posts/${post.get('id')}/reply-history.json?max_replies=${Discourse.SiteSettings.max_reply_history}`; - const store = this.store; - return Discourse.ajax(url).then(result => { - return result.map(p => this.storePost(store.createRecord('post', p))); - }).then(replyHistory => { - post.set('replyHistory', replyHistory); - }); + return resolved; }, /** diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index ff638cef690..8565cfc7d1b 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 9c66da96ddd..af365c7bad2 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 c40213a72ea..a92174bc97a 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 24d0638a53d..e6cd21304d4 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -224,34 +224,37 @@ const Topic = RestModel.extend({ .then(function () { self.set('archetype', 'regular'); }); }, - estimatedReadingTime: function() { - const wordCount = this.get('word_count'); - if (!wordCount) return; - - return Math.floor(wordCount / Discourse.SiteSettings.read_time_word_count); - }.property('word_count'), - toggleBookmark() { - if (this.get("bookmarking")) { return; } + if (this.get('bookmarking')) { return Ember.RSVP.Promise.resolve(); } this.set("bookmarking", true); - const self = this, - stream = this.get('postStream'), - posts = Em.get(stream, 'posts'), - firstPost = posts && posts[0] && posts[0].get('post_number') === 1 && posts[0], - bookmark = !this.get('bookmarked'), - path = bookmark ? '/bookmark' : '/remove_bookmarks'; + const stream = this.get('postStream'); + const posts = Em.get(stream, 'posts'); + const firstPost = posts && posts[0] && posts[0].get('post_number') === 1 && posts[0]; + const bookmark = !this.get('bookmarked'); + const path = bookmark ? '/bookmark' : '/remove_bookmarks'; - const toggleBookmarkOnServer = function() { - return Discourse.ajax('/t/' + self.get('id') + path, { - type: 'PUT', - }).then(function() { - self.toggleProperty('bookmarked'); - if (bookmark && firstPost) { firstPost.set('bookmarked', true); } - if (!bookmark && posts) { - posts.forEach((post) => post.get('bookmarked') && post.set('bookmarked', false)); + const toggleBookmarkOnServer = () => { + return Discourse.ajax(`/t/${this.get('id')}${path}`, { type: 'PUT' }).then(() => { + this.toggleProperty('bookmarked'); + if (bookmark && firstPost) { + firstPost.set('bookmarked', true); + return [firstPost.id]; } - }).catch(function(error) { + if (!bookmark && posts) { + + const updated = []; + posts.forEach(post => { + if (post.get('bookmarked')) { + post.set('bookmarked', false); + updated.push(post.get('id')); + } + }); + return updated; + } + + return []; + }).catch(error => { let showGenericError = true; if (error && error.responseText) { try { @@ -265,28 +268,26 @@ const Topic = RestModel.extend({ } throw error; - }).finally(function() { - self.set("bookmarking", false); - }); + }).finally(() => this.set('bookmarking', false)); }; - let unbookmarkedPosts = []; + const unbookmarkedPosts = []; if (!bookmark && posts) { - posts.forEach((post) => post.get('bookmarked') && unbookmarkedPosts.push(post)); + posts.forEach(post => post.get('bookmarked') && unbookmarkedPosts.push(post)); } - if (unbookmarkedPosts.length > 1) { - return bootbox.confirm( - I18n.t("bookmarks.confirm_clear"), - I18n.t("no_value"), - I18n.t("yes_value"), - function (confirmed) { - if (confirmed) { return toggleBookmarkOnServer(); } - } - ); - } else { - return toggleBookmarkOnServer(); - } + return new Ember.RSVP.Promise(resolve => { + if (unbookmarkedPosts.length > 1) { + bootbox.confirm( + I18n.t("bookmarks.confirm_clear"), + I18n.t("no_value"), + I18n.t("yes_value"), + confirmed => confirmed ? toggleBookmarkOnServer().then(resolve) : resolve() + ); + } else { + toggleBookmarkOnServer().then(resolve); + } + }); }, createInvite(emailOrUsername, groupNames) { @@ -390,25 +391,6 @@ const Topic = RestModel.extend({ }); }, - // Is the reply to a post directly below it? - isReplyDirectlyBelow(post) { - const posts = this.get('postStream.posts'); - const postNumber = post.get('post_number'); - if (!posts) return; - - const postBelow = posts[posts.indexOf(post) + 1]; - - // If the post directly below's reply_to_post_number is our post number or we are quoted, - // it's considered directly below. - // - // TODO: we don't carry information about quoting, this leaves this code fairly fragile - // instead we should start shipping quote meta data with posts, but this will add at least - // 1 query to the topics page - // - return postBelow && (postBelow.get('reply_to_post_number') === postNumber || - postBelow.get('cooked').indexOf('data-post="'+ postNumber + '"') >= 0 - ); - }, hasExcerpt: Em.computed.notEmpty('excerpt'), 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 7d66de697d4..96c4cea3292 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 0ace34b5bb9..b25208f6ceb 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 890f0729f85..990a8821572 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 a4a6fa58cc5..6e17a41b5b1 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/routes/user-index.js.es6 b/app/assets/javascripts/discourse/routes/user-index.js.es6 index 24a536237e2..1653ef0bc8d 100644 --- a/app/assets/javascripts/discourse/routes/user-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-index.js.es6 @@ -4,7 +4,7 @@ export default Discourse.Route.extend({ // HACK: Something with the way the user card intercepts clicks seems to break how the // transition into a user's activity works. This makes the back button work on mobile // where there is no user card as well as desktop where there is. - if (Discourse.Mobile.mobileView) { + if (this.site.mobileView) { this.replaceWith('userActivity'); } else { this.transitionTo('userActivity'); diff --git a/app/assets/javascripts/discourse/templates/badges/show.hbs b/app/assets/javascripts/discourse/templates/badges/show.hbs index 7cfada24304..e41eb32ef1d 100644 --- a/app/assets/javascripts/discourse/templates/badges/show.hbs +++ b/app/assets/javascripts/discourse/templates/badges/show.hbs @@ -29,7 +29,7 @@ {{#link-to 'user' user}} {{avatar user imageSize="extra_large"}}
- {{poster-name post=user}} +
{{user.username}}
{{/link-to}}
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 eb2d2dd5f28..00000000000 --- 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 091f0e07df2..821ed4ff910 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 671b85d945e..00000000000 --- 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' replyCount=topic.replyCount readingTime=topic.estimatedReadingTime}}}

- {{else}} -

{{{i18n 'summary.description' replyCount=topic.replyCount}}}

- {{/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 0389b002ff8..00000000000 --- 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 03bfc30a266..00000000000 --- 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 d8c949d9b0e..00000000000 --- 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-placeholder.hbs b/app/assets/javascripts/discourse/templates/post-placeholder.hbs deleted file mode 100644 index 936863f191e..00000000000 --- a/app/assets/javascripts/discourse/templates/post-placeholder.hbs +++ /dev/null @@ -1,13 +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 42c11c5246f..00000000000 --- 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 1606d6d6f83..00000000000 --- 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 4bb3b7ebd79..e74b36dad02 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/templates/user/messages.hbs b/app/assets/javascripts/discourse/templates/user/messages.hbs index afc7d0f4278..9f4c6b62f85 100644 --- a/app/assets/javascripts/discourse/templates/user/messages.hbs +++ b/app/assets/javascripts/discourse/templates/user/messages.hbs @@ -1,8 +1,8 @@
- {{#unless mobileView}} - {{#if showNewPM}} - {{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}} - {{/if}} + {{#unless site.mobileView}} + {{#if showNewPM}} + {{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}} + {{/if}} {{/unless}} {{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=currentPath}} @@ -47,10 +47,10 @@ - {{#if mobileView}} - {{#if showNewPM}} - {{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}} - {{/if}} + {{#if site.mobileView}} + {{#if showNewPM}} + {{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}} + {{/if}} {{/if}} {{#if canArchive}} 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 8cf0c4bd346..00000000000 --- 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 4bf5f9f2f87..00000000000 --- 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 ae96413c76f..00000000000 --- 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 e6d85a85c2d..00000000000 --- 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 988c166127b..00000000000 --- 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/modal-body.js.es6 b/app/assets/javascripts/discourse/views/modal-body.js.es6 index f164c8dbbe5..05998dac405 100644 --- a/app/assets/javascripts/discourse/views/modal-body.js.es6 +++ b/app/assets/javascripts/discourse/views/modal-body.js.es6 @@ -9,7 +9,7 @@ export default Ember.View.extend({ $('#discourse-modal').modal('show'); // Focus on first element - if (!Discourse.Mobile.mobileView && this.get('focusInput')) { + if (!this.site.mobileView && this.get('focusInput')) { Em.run.schedule('afterRender', () => this.$('input:first').focus()); } 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 813677edcdf..00000000000 --- 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-placeholder.js.es6 b/app/assets/javascripts/discourse/views/post-placeholder.js.es6 deleted file mode 100644 index d73c6ce7546..00000000000 --- a/app/assets/javascripts/discourse/views/post-placeholder.js.es6 +++ /dev/null @@ -1 +0,0 @@ -export default Ember.View.extend({ templateName: 'post-placeholder' }); 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 8dd08a5b612..00000000000 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ /dev/null @@ -1,383 +0,0 @@ -import ScreenTrack from 'discourse/lib/screen-track'; -import { number } from 'discourse/lib/formatter'; -import DiscourseURL from 'discourse/lib/url'; -import { default as computed, on } from 'ember-addons/ember-computed-decorators'; -import { fmt } from 'discourse/lib/computed'; -import { isValidLink } from 'discourse/lib/click-track'; - -const DAY = 60 * 50 * 1000; - -const PostView = Discourse.GroupedView.extend(Ember.Evented, { - classNames: ['topic-post', 'clearfix'], - classNameBindings: ['needsModeratorClass:moderator:regular', - 'selected', - 'post.hidden:post-hidden', - 'post.deleted:deleted', - 'post.topicOwner:topic-owner', - 'groupNameClass', - 'post.wiki:wiki', - 'whisper'], - - post: Ember.computed.alias('content'), - postElementId: fmt('post.post_number', 'post_%@'), - likedUsers: null, - - @on('init') - initLikedUsers() { - this.set('likedUsers', []); - }, - - @computed('post.post_type') - whisper(postType) { - return postType === this.site.get('post_types.whisper'); - }, - - templateName: function() { - return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post'; - }.property('post.post_type'), - - historyHeat: function() { - const updatedAt = this.get('post.updated_at'); - if (!updatedAt) { return; } - - // Show heat on age - const rightNow = new Date().getTime(), - updatedAtDate = new Date(updatedAt).getTime(); - - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_low)) return 'heatmap-high'; - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_medium)) return 'heatmap-med'; - if (updatedAtDate > (rightNow - DAY * Discourse.SiteSettings.history_hours_high)) return 'heatmap-low'; - }.property('post.updated_at'), - - needsModeratorClass: function() { - return (this.get('post.post_type') === this.site.get('post_types.moderator_action')) || - (this.get('post.topic.is_warning') && this.get('post.firstPost')); - }.property('post.post_type'), - - groupNameClass: function() { - const primaryGroupName = this.get('post.primary_group_name'); - if (primaryGroupName) { - return "group-" + primaryGroupName; - } - }.property('post.primary_group_name'), - - showExpandButton: function() { - if (this.get('controller.firstPostExpanded')) { return false; } - - const post = this.get('post'); - return post.get('post_number') === 1 && post.get('topic.expandable_first_post'); - }.property('post.post_number', 'controller.firstPostExpanded'), - - // If the cooked content changed, add the quote controls - cookedChanged: function() { - Em.run.scheduleOnce('afterRender', this, '_cookedWasChanged'); - }.observes('post.cooked'), - - _cookedWasChanged() { - this.trigger('postViewUpdated', this.$()); - this._insertQuoteControls(); - }, - - mouseUp(e) { - if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) { - this.get('controller').toggledSelectedPost(this.get('post')); - } - }, - - selected: function() { - return this.get('controller').postSelected(this.get('post')); - }.property('controller.selectedPostsCount'), - - canSelectReplies: function() { - if (this.get('post.reply_count') === 0) { return false; } - return !this.get('selected'); - }.property('post.reply_count', 'selected'), - - selectPostText: function() { - return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select'); - }.property('selected', 'controller.selectedPostsCount'), - - repliesShown: Em.computed.gt('post.replies.length', 0), - - _updateQuoteElements($aside, desc) { - let navLink = ""; - const quoteTitle = I18n.t("post.follow_quote"), - postNumber = $aside.data('post'); - - if (postNumber) { - - // If we have a topic reference - let topicId, topic; - if (topicId = $aside.data('topic')) { - topic = this.get('controller.content'); - - // If it's the same topic as ours, build the URL from the topic object - if (topic && topic.get('id') === topicId) { - navLink = ``; - } 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 = href === lc.url; - - // this might be an attachment - if (lc.internal && /^\/uploads\//.test(lc.url)) { - valid = href.indexOf(lc.url) >= 0; - } - - if (valid) { - // don't display badge counts on category badge & oneboxes (unless when explicitely stated) - if (isValidLink($link)) { - $link.append("" + 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/quote-button.js.es6 b/app/assets/javascripts/discourse/views/quote-button.js.es6 index da9ba9040c8..681523377bc 100644 --- a/app/assets/javascripts/discourse/views/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/views/quote-button.js.es6 @@ -1,3 +1,11 @@ +// we don't want to deselect when we click on buttons that use it +function ignoreElements(e) { + const $target = $(e.target); + return $target.hasClass('quote-button') || + $target.closest('.create').length || + $target.closest('.reply-new').length; +} + export default Ember.View.extend({ classNames: ['quote-button'], classNameBindings: ['visible'], @@ -45,11 +53,7 @@ export default Ember.View.extend({ .on("mousedown.quote-button", function(e) { view.set('isMouseDown', true); - const $target = $(e.target); - // we don't want to deselect when we click on buttons that use it - if ($target.hasClass('quote-button') || - $target.closest('.create').length || - $target.closest('.reply-new').length) return; + if (ignoreElements(e)) { return; } // deselects only when the user left click // (allows anyone to `extend` their selection using shift+click) @@ -58,6 +62,8 @@ export default Ember.View.extend({ !e.shiftKey) controller.deselectText(); }) .on('mouseup.quote-button', function(e) { + if (ignoreElements(e)) { return; } + view.selectText(e.target, controller); view.set('isMouseDown', false); }) 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 e1f2cb45bc6..00000000000 --- 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/share.js.es6 b/app/assets/javascripts/discourse/views/share.js.es6 index 6c1d0f18481..c15f26d8359 100644 --- a/app/assets/javascripts/discourse/views/share.js.es6 +++ b/app/assets/javascripts/discourse/views/share.js.es6 @@ -82,7 +82,7 @@ export default Ember.View.extend({ $shareLink.css({top: "" + y + "px"}); - if (!Discourse.Mobile.mobileView) { + if (!self.site.mobileView) { $shareLink.css({left: "" + x + "px"}); } diff --git a/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 b/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 index bc63cd182a8..ab3992ec6df 100644 --- a/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 @@ -6,7 +6,7 @@ export default ContainerView.extend({ @on('init') createButtons() { - const mobileView = Discourse.Mobile.mobileView; + const mobileView = this.site.mobileView; if (!mobileView && this.currentUser.get('staff')) { const viewArgs = {action: 'showTopicAdminMenu', title: 'topic_admin_menu', icon: 'wrench', position: 'absolute'}; 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 5bd42eb3675..00000000000 --- 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/views/topic-progress.js.es6 b/app/assets/javascripts/discourse/views/topic-progress.js.es6 index 56e3c0ca713..7454c99d2dd 100644 --- a/app/assets/javascripts/discourse/views/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-progress.js.es6 @@ -76,7 +76,7 @@ export default Ember.View.extend({ _focusWhenOpened: function() { // Don't focus on mobile or touch - if (Discourse.Mobile.mobileView || this.capabilities.isIOS) { + if (this.site.mobileView || this.capabilities.isIOS) { return; } diff --git a/app/assets/javascripts/discourse/views/topic.js.es6 b/app/assets/javascripts/discourse/views/topic.js.es6 index 93b3adb1297..96cdf5826d7 100644 --- a/app/assets/javascripts/discourse/views/topic.js.es6 +++ b/app/assets/javascripts/discourse/views/topic.js.es6 @@ -185,7 +185,7 @@ function highlight(postNumber) { }); } -listenForViewEvent(TopicView, 'post:highlight', function(postNumber) { +listenForViewEvent(TopicView, 'post:highlight', postNumber => { Ember.run.scheduleOnce('afterRender', null, highlight, postNumber); }); 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 00000000000..a09bc5b36bf --- /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 00000000000..5b4f6e11945 --- /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.widget-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.widget-button`).removeClass('d-hover').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 00000000000..2b6066eb596 --- /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/connector.js.es6 b/app/assets/javascripts/discourse/widgets/connector.js.es6 new file mode 100644 index 00000000000..c132666a412 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/connector.js.es6 @@ -0,0 +1,39 @@ +export default class Connector { + + constructor(widget, opts) { + this.widget = widget; + this.opts = opts; + } + + init() { + const $elem = $(`
`); + const elem = $elem[0]; + + const { opts, widget } = this; + Ember.run.next(() => { + + const mounted = widget._findView(); + + let context; + if (opts.context === 'model') { + const model = widget.findAncestorModel(); + context = model; + } + + const view = Ember.View.create({ + container: widget.container, + templateName: opts.templateName, + context + }); + mounted._connected.push(view); + + view.renderer.replaceIn(view, $elem[0]); + }); + + return elem; + } + + update() { } +} + +Connector.prototype.type = 'Widget'; diff --git a/app/assets/javascripts/discourse/widgets/decorator-helper.js.es6 b/app/assets/javascripts/discourse/widgets/decorator-helper.js.es6 new file mode 100644 index 00000000000..53ba38e87d6 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/decorator-helper.js.es6 @@ -0,0 +1,94 @@ +import Connector from 'discourse/widgets/connector'; +import { h } from 'virtual-dom'; +import PostCooked from 'discourse/widgets/post-cooked'; +import RawHtml from 'discourse/widgets/raw-html'; + +class DecoratorHelper { + constructor(widget, attrs, state) { + this.widget = widget; + this.attrs = attrs; + this.state = state; + } + + /** + * The `h` helper allows you to build up a virtual dom easily. + * + * Example: + * + * ``` + * // renders `

paragraph

` + * return helper.h('div.some-class', helper.h('p', 'paragraph')); + * ``` + **/ + // h() is attached via `prototype` below + + /** + * Returns the model associated with this widget. When decorating + * posts this will normally be the post. + * + * Example: + * + * ``` + * const post = helper.getModel(); + * console.log(post.get('id')); + * ``` + **/ + getModel() { + return this.widget.findAncestorModel(); + } + + /** + * If your decorator must produce raw HTML, you can use this helper + * to display it. It is preferred to use the `h` helper and create + * the HTML yourself whenever possible. + * + * Example: + * + * ``` + * return helper.rawHtml(`

I will be displayedCook me

`); + * ``` + **/ + cooked(cooked) { + return new PostCooked({ cooked }); + } + + /** + * You can use this bridge to mount an Ember View inside the virtual + * DOM post stream. Note that this is a bit bizarre, as our core app + * is rendered in Ember, then we switch to a virtual dom, and this + * allows part of that virtual dom to use Ember again! + * + * It really only exists as backwards compatibility for some old + * plugins that would be difficult to update otherwise. There are + * performance reasons not to use this, so be careful and avoid + * using it whenever possible. + * + * Example: + * + * ``` + * helper.connect({ templateName: 'my-handlebars-template' }); + * ``` + **/ + connect(details) { + return new Connector(this.widget, details); + } + +} +DecoratorHelper.prototype.h = h; + +export default DecoratorHelper; 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 00000000000..767044ccb9c --- /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.routeTo(this.attrs.shareUrl); + } +}); + +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, shareUrl: attrs.shareUrl }) + ]), + 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 00000000000..e69de29bb2d 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 00000000000..16590124ffa --- /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 00000000000..1847ca4710e --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 @@ -0,0 +1,208 @@ +import { isValidLink } from 'discourse/lib/click-track'; +import { number } from 'discourse/lib/formatter'; + +const _decorators = []; + +// Don't call this directly: use `plugin-api/decorateCooked` +export function addDecorator(cb) { + _decorators.push(cb); +} + +export default class PostCooked { + + constructor(attrs, getModel) { + this.attrs = attrs; + this.expanding = false; + this._highlighted = false; + this.getModel = getModel; + } + + 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); + + _decorators.forEach(cb => cb($html, this.getModel)); + 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() - (this.attrs.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 = href === lc.url; + + // this might be an attachment + if (lc.internal && /^\/uploads\//.test(lc.url)) { + 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.attrs.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 00000000000..5ae62a3f1ce --- /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 00000000000..d14d1b811a6 --- /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 00000000000..f79ec5e5c4d --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-gutter.js.es6 @@ -0,0 +1,69 @@ +import { iconNode } from 'discourse/helpers/fa-icon'; +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import RawHtml from 'discourse/widgets/raw-html'; + +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 = [new RawHtml({ html: `${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 00000000000..9186c80c80a --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -0,0 +1,353 @@ +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 = {}; +const _extraButtons = {}; + +export function addButton(name, builder) { + _extraButtons[name] = builder; +} + +function registerButton(name, builder) { + _builders[name] = builder; +} + +registerButton('like', attrs => { + if (!attrs.showLike) { return; } + const className = attrs.liked ? 'toggle-like has-like fade-out' : 'toggle-like like'; + + const button = { + action: 'like', + icon: 'heart', + className + }; + + if (attrs.canToggleLike) { + button.title = attrs.liked ? 'post.controls.undo_like' : 'post.controls.like'; + } else if (attrs.liked) { + button.title = 'post.controls.has_liked'; + button.disabled = true; + } + return button; +}); + +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 (!attrs.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); + } + + Object.keys(_extraButtons).forEach(k => { + const builder = _extraButtons[k]; + if (builder) { + const buttonAtts = builder(attrs, this.state, this.siteSettings); + if (buttonAtts) { + const { position, beforeButton } = buttonAtts; + delete buttonAtts.position; + + let button = this.attach('button', buttonAtts); + + if (beforeButton) { + button = h('span', [beforeButton(h), button]); + } + + if (button) { + switch(position) { + case 'first': + visibleButtons.unshift(button); + break; + case 'second-last-hidden': + if (!state.collapsed) { + visibleButtons.splice(visibleButtons.length-2, 0, button); + } + break; + default: + visibleButtons.push(button); + break; + } + } + } + } + }); + + 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`); + $heart.closest('button').addClass('has-like'); + + 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-placeholder.js.es6 b/app/assets/javascripts/discourse/widgets/post-placeholder.js.es6 new file mode 100644 index 00000000000..f12fb26f34c --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-placeholder.js.es6 @@ -0,0 +1,17 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; + +export default createWidget('post-placeholder', { + tagName: 'article.placeholder', + + html() { + return h('div.row', [ + h('div.topic-avatar', h('div.placeholder-avatar')), + h('div.topic-body', [ + h('div.placeholder-text'), + h('div.placeholder-text'), + h('div.placeholder-text') + ]) + ]); + } +}); 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 00000000000..c0dcea64eba --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 @@ -0,0 +1,74 @@ +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'; +import { avatarFor } from 'discourse/widgets/post'; + +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.onscreen-post.clearfix', + + buildId(attrs) { + return `post_${attrs.post_number}`; + }, + + buildClasses(attrs) { + if (attrs.deleted) { return 'deleted'; } + }, + + 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' + })); + } + + contents.push(avatarFor.call(this, 'small', { + template: attrs.avatar_template, + username: attrs.avatar, + url: attrs.usernameUrl + })); + + 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 00000000000..f7fb75e1436 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post-stream.js.es6 @@ -0,0 +1,83 @@ +import { createWidget } from 'discourse/widgets/widget'; +import transformPost from 'discourse/lib/transform-post'; +import { Placeholder } from 'discourse/lib/posts-with-placeholders'; + +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.after ? attrs.gaps.after : {}; + + let prevPost; + let prevDate; + + const mobileView = this.site.mobileView; + 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 00000000000..87cdb465854 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -0,0 +1,419 @@ +import PostCooked from 'discourse/widgets/post-cooked'; +import { createWidget, applyDecorators } 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'); + } +}); + +class DecoratorHelper { + constructor(widget) { + this.container = widget.container; + this._widget = widget; + } + + getModel() { + return this._widget.findAncestorModel(); + } +} + +createWidget('post-contents', { + buildKey: attrs => `post-contents-${attrs.id}`, + + defaultState() { + return { expandedFirstPost: false, repliesBelow: [] }; + }, + + buildClasses(attrs) { + const classes = ['regular']; + if (!this.state.repliesShown) { + classes.push('contents'); + } + if (showReplyTab(attrs, this.siteSettings)) { + classes.push('avoid-tab'); + } + return classes; + }, + + html(attrs, state) { + let result = [new PostCooked(attrs, new DecoratorHelper(this))]; + result = result.concat(applyDecorators(this, 'after-cooked', attrs, state)); + + if (attrs.cooked_hidden) { + result.push(this.attach('expand-hidden', attrs)); + } + + if (!state.expandedFirstPost && attrs.expandablePost) { + result.push(this.attach('expand-post-button', attrs)); + } + + const extraState = { state: { repliesShown: !!state.repliesBelow.length } }; + result.push(this.attach('post-menu', attrs, extraState)); + + const repliesBelow = state.repliesBelow; + if (repliesBelow.length) { + result.push(h('section.embedded-posts.bottom', repliesBelow.map(p => this.attach('embedded-post', p)))); + } + + return result; + }, + + toggleRepliesBelow() { + if (this.state.repliesBelow.length) { + this.state.repliesBelow = []; + return; + } + + const post = this.findAncestorModel(); + const topicUrl = post ? post.get('topic.url') : null; + return this.store.find('post-reply', { postId: this.attrs.id }).then(posts => { + this.state.repliesBelow = posts.map(p => { + p.shareUrl = `${topicUrl}/${p.post_number}`; + return transformBasicPost(p); + }); + }); + }, + + expandFirstPost() { + const post = this.findAncestorModel(); + return post.expand().then(() => this.state.expandedFirstPost = true); + } +}); + +createWidget('post-body', { + tagName: 'div.topic-body', + + html(attrs) { + const postContents = this.attach('post-contents', attrs); + const result = [this.attach('post-meta-data', attrs), postContents]; + + result.push(this.attach('actions-summary', attrs)); + if (attrs.showTopicMap) { + result.push(this.attach('topic-map', attrs)); + } + + return result; + } +}); + +createWidget('post-article', { + tagName: 'article.boxed.onscreen-post', + 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 = [h('a.tabLoc', { attributes: { href: ''} })]; + 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 (this.attrs.mobileView) { + DiscourseURL.jumpToPost(replyPostNumber); + return Ember.RSVP.Promise.resolve(); + } + + if (this.state.repliesAbove.length) { + this.state.repliesAbove = []; + return Ember.RSVP.Promise.resolve(); + } else { + const post = this.findAncestorModel(); + const topicUrl = post ? post.get('topic.url') : null; + return this.store.find('post-reply-history', { postId: this.attrs.id }).then(posts => { + this.state.repliesAbove = posts.map((p) => { + p.shareUrl = `${topicUrl}/${p.post_number}`; + return transformBasicPost(p); + }); + }); + } + }, + +}); + +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 || attrs.user_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')) { + const promise = likeAction.togglePromise(post); + this.scheduleRerender(); + return promise; + } + }, + + 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 00000000000..c9b2a45ab5d --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/poster-name.js.es6 @@ -0,0 +1,70 @@ +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)); + } + + // const cfs = attrs.userCustomFields; + // if (cfs) { + // _callbacks.forEach(cb => { + // const result = cb(cfs, attrs); + // if (result) { + // + // } + // }); + // } + 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 00000000000..9fe250d5c65 --- /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 00000000000..a1eddd74b15 --- /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 00000000000..721a048032b --- /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.clearfix', + + 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 00000000000..9816f35b417 --- /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', { replyCount: attrs.topicReplyCount, readingTime }); + } + return I18n.t('summary.description', { replyCount: attrs.topicReplyCount }); + }, + + 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 00000000000..00d6f7e93e4 --- /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 00000000000..f959c8fc77d --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -0,0 +1,257 @@ +import { WidgetClickHook, WidgetClickOutsideHook } from 'discourse/widgets/click-hook'; +import { h } from 'virtual-dom'; +import DecoratorHelper from 'discourse/widgets/decorator-helper'; + +function emptyContent() { } + +const _registry = {}; +let _dirty = {}; + +export function keyDirty(key) { + _dirty[key] = true; +} + +export function renderedKey(key) { + delete _dirty[key]; +} + +const _decorators = {}; + +export function decorateWidget(widgetName, cb) { + _decorators[widgetName] = _decorators[widgetName] || []; + _decorators[widgetName].push(cb); +} + +export function applyDecorators(widget, type, attrs, state) { + const decorators = _decorators[`${widget.name}:${type}`] || []; + + if (decorators.length) { + const helper = new DecoratorHelper(widget, attrs, state); + return decorators.map(d => d(helper)); + } + + return []; +} + +function drawWidget(builder, attrs, state) { + const properties = {}; + + if (this.buildClasses) { + let classes = this.buildClasses(attrs, state) || []; + if (!Array.isArray(classes)) { classes = [classes]; } + + const customClasses = applyDecorators(this, 'classNames', attrs, state); + if (customClasses && customClasses.length) { + classes = classes.concat(customClasses); + } + + 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); + } + + let contents = this.html(attrs, state); + if (this.name) { + const beforeContents = applyDecorators(this, 'before', attrs, state) || []; + const afterContents = applyDecorators(this, 'after', attrs, state) || []; + contents = beforeContents.concat(contents).concat(afterContents); + } + + return h(this.tagName || 'div', properties, contents); +} + +export function createWidget(name, opts) { + const result = class CustomWidget extends Widget {}; + + if (name) { + _registry[name] = result; + } + + opts.name = name; + 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'); + this.appEvents = container.lookup('app-events: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] && !_dirty['*']) { + return prev.vnode; + } + + renderedKey(prev.key); + } + + 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) { + // TODO: Use ember closure actions + 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/env.js b/app/assets/javascripts/env.js index e3035e26230..5537a6c10bc 100644 --- a/app/assets/javascripts/env.js +++ b/app/assets/javascripts/env.js @@ -2,3 +2,7 @@ window.ENV = { }; window.Discourse = {}; Discourse.SiteSettings = {}; + +window.EmberENV = window.EmberENV || {}; +window.EmberENV['FORCE_JQUERY'] = true; + diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 15c4cb0747f..0c11c439890 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -57,11 +57,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 @@ -109,3 +107,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 29ee2c53285..5d6d3e6d2eb 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -38,4 +38,7 @@ //= require break_string //= require buffered-proxy //= require jquery.autoellipsis-1.0.10.min.js +//= require virtual-dom +//= require virtual-dom-amd +//= require highlight.js //= require_tree ./discourse/ember diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 5b3ecebf49c..2c4529ce83c 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -340,6 +340,9 @@ table.md-table { } .whisper { + .post-info.whisper { + margin-left: 0.5em; + } .topic-body { .cooked { font-style: italic; diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index f29ff7fdbb9..3fcc69dbac3 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 { @@ -34,16 +34,6 @@ h1 .topic-statuses .topic-status i { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); } - .gutter { - .reply-new { - .discourse-no-touch & { - opacity:0; - transition: opacity 0.7s ease-in-out; - } - .discourse-touch & {opacity: 1;} - } - } - .actions .fade-out { .discourse-no-touch & { opacity: 0.7; @@ -55,14 +45,25 @@ h1 .topic-statuses .topic-status i { &:hover .actions .fade-out, .selected .actions .fade-out { opacity: 1; } +} - &:hover .gutter, .selected .gutter { - .reply-new, - .track-link { - opacity:1; +.topic-post { + .gutter { + .reply-new { + .discourse-no-touch & { + opacity:0; + transition: opacity 0.7s ease-in-out; } + .discourse-touch & {opacity: 1;} + } } + &:hover .gutter, .selected .gutter { + .reply-new, + .track-link { + opacity:1; + } + } } section.post-menu-area { @@ -138,65 +139,64 @@ nav.post-controls { background: transparent; border: none; margin-left: 3px; - transition: all linear 0.15s; - &:hover { - background: dark-light-diff($primary, $secondary, 90%, -60%); - color: $primary; - } + &.d-hover { + background: dark-light-diff($primary, $secondary, 90%, -60%); + color: $primary; + } - &:active { - box-shadow: inset 0 1px 3px rgba(0,0,0, .4); - } + &:active { + box-shadow: inset 0 1px 3px rgba(0,0,0, .4); + } - &.hidden { - display: none; - } - &.admin { - position: relative; - } + &.hidden { + display: none; + } + &.admin { + position: relative; + } - &.delete:hover { - background: $danger; - color: $secondary; - } + &.delete.d-hover { + background: $danger; + color: $secondary; + } - &.like:hover { - color: $love; - background: dark-light-diff($love, $secondary, 85%, -60%) - } + &.like.d-hover { + color: $love; + background: dark-light-diff($love, $secondary, 85%, -60%) + } - &.has-like {color: $love;} - &.has-like[disabled]:hover { - background: transparent; - } - &.has-like[disabled]:active { - box-shadow: none; - } + &.has-like {color: $love;} + &.has-like[disabled]:hover { + background: transparent; + } + &.has-like[disabled]:active { + box-shadow: none; + } - &.wikied { - color: green; - } + &.wikied { + color: green; + } - &.bookmark {padding: 8px 11px; } + &.bookmark {padding: 8px 11px; } - .read-icon { - &:before { - font-family: "FontAwesome"; - content: "\f02e"; - } - &.unseen { - &:before { - content: "\f097"; - } - } - &.bookmarked { - &:before { - color: $tertiary; - } - } + .read-icon { + &:before { + font-family: "FontAwesome"; + content: "\f02e"; + } + &.unseen { + &:before { + content: "\f097"; } } + &.bookmarked { + &:before { + color: $tertiary; + } + } + } + } .post-admin-menu { background-color: $secondary; @@ -208,7 +208,6 @@ nav.post-controls { bottom: -2px; right: 15px; z-index: 1000; - display: none; h3 { margin-top: 0; @@ -589,11 +588,15 @@ a.mention, a.mention-group { } .deleted { - .topic-body, .small-action { + .topic-body { background-color: dark-light-diff(rgba($danger,.7), $secondary, 50%, -60%); } } +.small-action.deleted { + background-color: dark-light-diff(rgba($danger,.7), $secondary, 50%, -60%); +} + #share-link { width: 365px; margin-left: -4px; @@ -1002,9 +1005,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-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index 496f1ef92a2..5b5c02021da 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -2,16 +2,16 @@ /* may not need this */ } -.time-gap { - border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); +.small-action { + border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); color: lighten($primary, 50%); padding-bottom: 3px; - margin-bottom: 10px; text-transform: uppercase; font-weight: bold; - .topic-avatar { - margin: 0 5px 0 10px; - } +} + +.small-action .topic-avatar { + margin-top: 5px; } .topic-post { @@ -19,6 +19,11 @@ padding: 6px 0; } +.post-stream { + padding-bottom: 30px; +} + + span.badge-posts { margin-right: 5px; } @@ -523,24 +528,6 @@ span.highlighted { } } -.small-action .small-action-desc { - p { - padding-top: 0; - } - .custom-message { - margin-left: -40px; - } -} - -.small-action .topic-avatar { - padding: 0; - margin: 0; -} - -.small-action.time-gap .topic-avatar { - margin-top: -5px; -} - .gap.jagged-border { margin-left: -10px; margin-right: -10px; diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 2b421b8ae23..fa3d7d45989 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -162,7 +162,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/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index 1e1f3869a10..f1c3ec28631 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -107,7 +107,6 @@ module HasCustomFields end end - end def reload(options = nil) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4d6575b7776..ff7d545b579 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,6 +17,11 @@ <%- end %> + + <%= script "preload_store" %> <%= script "locales/#{I18n.locale}" %> <%= script "ember_jquery" %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6891ca54329..8e434f3bd32 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1535,17 +1535,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 f6d09f61791..8a9a652988b 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -931,7 +931,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 34bef2d4291..c522619eb67 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -762,7 +762,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 b2adca516a2..85ac95eb2ad 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 22dc369c50b..e22fb383208 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 5b66093ba5d..d96cbb7f1ef 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 ee1b40ac851..cf9595c1640 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 a86d2f685ed..4c6867a4902 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 008299cdde9..e79e6d50eb2 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 3d12040e835..969ba7c39d3 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 ab3086d5991..98e5f8cdd5e 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -631,7 +631,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 14b7ff0b02c..8c7773eddda 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 b1f0239457e..75d7199454b 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 f90e504063e..adabb2c2742 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 fb549bbba82..5d7a53ac75b 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -754,7 +754,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 a1b6008449a..9ace8634990 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -775,7 +775,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 63eb1ece998..a2c0e784690 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 c56bf44e29b..0f044e9a0ca 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 08abef9992d..b7bbdfc4b79 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 04ee6a797f9..2a93ce0121e 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 275dedf24e3..64990a3e2e6 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 169985693c7..d54524fe68a 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/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 index 9813a1d6eac..367607f8f2c 100644 --- a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 +++ b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 @@ -1,10 +1,11 @@ -import { decorateCooked } from "discourse/lib/plugin-api"; +import { withPluginApi } from 'discourse/lib/plugin-api'; export default { name: "apply-details", - initialize(container) { - decorateCooked(container, $elem => $("details", $elem).details()); + initialize() { + withPluginApi('0.1', api => { + api.decorateCooked($elem => $("details", $elem).details()); + }); } - }; diff --git a/plugins/lazyYT/assets/javascripts/initializers/lazyYT.js.es6 b/plugins/lazyYT/assets/javascripts/initializers/lazyYT.js.es6 index 0743a30c371..3453c425fff 100644 --- a/plugins/lazyYT/assets/javascripts/initializers/lazyYT.js.es6 +++ b/plugins/lazyYT/assets/javascripts/initializers/lazyYT.js.es6 @@ -1,13 +1,10 @@ -/** - Apply lazyYT when the app boots -**/ -import { decorateCooked } from 'discourse/lib/plugin-api'; +import { withPluginApi } from 'discourse/lib/plugin-api'; export default { name: "apply-lazyYT", - initialize: function(container) { - decorateCooked(container, function($elem) { - $('.lazyYT', $elem).lazyYT(); + initialize() { + withPluginApi('0.1', api => { + api.decorateCooked($elem => $('.lazyYT', $elem).lazyYT()); }); } }; diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index f96da54dd7c..c27957849d9 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -1,12 +1,8 @@ -import PostView from "discourse/views/post"; -import TopicController from "discourse/controllers/topic"; -import Post from "discourse/models/post"; - -import { on } from "ember-addons/ember-computed-decorators"; +import { withPluginApi } from 'discourse/lib/plugin-api'; function createPollView(container, post, poll, vote) { - const controller = container.lookup("controller:poll", { singleton: false }), - view = container.lookup("view:poll"); + const controller = container.lookup("controller:poll", { singleton: false }); + const view = container.lookup("view:poll"); controller.set("vote", vote); controller.setProperties({ model: poll, post }); @@ -15,88 +11,95 @@ function createPollView(container, post, poll, vote) { return view; } +let _pollViews; + +function initializePolls(api) { + + const TopicController = api.container.lookupFactory('controller:topic'); + TopicController.reopen({ + subscribe(){ + this._super(); + this.messageBus.subscribe("/polls/" + this.get("model.id"), msg => { + const post = this.get('model.postStream').findLoadedPost(msg.post_id); + if (post) { + post.set('polls', msg.polls); + } + }); + }, + unsubscribe(){ + this.messageBus.unsubscribe('/polls/*'); + this._super(); + } + }); + + const Post = api.container.lookupFactory('model:post'); + Post.reopen({ + _polls: null, + pollsObject: null, + + // we need a proper ember object so it is bindable + pollsChanged: function(){ + const polls = this.get("polls"); + if (polls) { + this._polls = this._polls || {}; + _.map(polls, (v,k) => { + const existing = this._polls[k]; + if (existing) { + this._polls[k].setProperties(v); + } else { + this._polls[k] = Em.Object.create(v); + } + }); + this.set("pollsObject", this._polls); + } + }.observes("polls") + }); + + function cleanUpPollViews() { + if (_pollViews) { + Object.keys(_pollViews).forEach(pollName => _pollViews[pollName].destroy()); + } + _pollViews = null; + } + + function createPollViews($elem, helper) { + const $polls = $('.poll', $elem); + if (!$polls.length) { return; } + + const post = helper.getModel(); + const votes = post.get('polls_votes') || {}; + + post.pollsChanged(); + + const polls = post.get("pollsObject"); + if (!polls) { return; } + + cleanUpPollViews(); + const postPollViews = {}; + + $polls.each((idx, pollElem) => { + const $div = $("
"); + const $poll = $(pollElem); + + const pollName = $poll.data("poll-name"); + const pollView = createPollView(helper.container, post, polls[pollName], votes[pollName]); + + $poll.replaceWith($div); + Em.run.next(() => pollView.renderer.replaceIn(pollView, $div[0])); + postPollViews[pollName] = pollView; + }); + + _pollViews = postPollViews; + } + + api.decorateCooked(createPollViews, { onlyStream: true }); + api.cleanupStream(cleanUpPollViews); +} + export default { name: "extend-for-poll", - initialize(container) { - - Post.reopen({ - // we need a proper ember object so it is bindable - pollsChanged: function(){ - const polls = this.get("polls"); - if (polls) { - this._polls = this._polls || {}; - _.map(polls, (v,k) => { - const existing = this._polls[k]; - if (existing) { - this._polls[k].setProperties(v); - } else { - this._polls[k] = Em.Object.create(v); - } - }); - this.set("pollsObject", this._polls); - } - }.observes("polls") - }); - - TopicController.reopen({ - subscribe(){ - this._super(); - this.messageBus.subscribe("/polls/" + this.get("model.id"), msg => { - const post = this.get('model.postStream').findLoadedPost(msg.post_id); - if (post) { - post.set('polls', msg.polls); - } - }); - }, - unsubscribe(){ - this.messageBus.unsubscribe('/polls/*'); - this._super(); - } - }); - - // overwrite polls - PostView.reopen({ - - @on("postViewInserted", "postViewUpdated") - _createPollViews($post) { - const post = this.get("post"), - votes = post.get("polls_votes") || {}; - - post.pollsChanged(); - const polls = post.get("pollsObject"); - - // don't even bother when there's no poll - if (!polls) { return; } - - // TODO inject cleanly into - - // clean-up if needed - this._cleanUpPollViews(); - - const pollViews = {}; - - // iterate over all polls - $(".poll", $post).each(function() { - const $div = $("
"), - $poll = $(this), - pollName = $poll.data("poll-name"), - pollView = createPollView(container, post, polls[pollName], votes[pollName]); - - $poll.replaceWith($div); - Em.run.next(() => pollView.renderer.replaceIn(pollView, $div[0])); - pollViews[pollName] = pollView; - }); - - this.set("pollViews", pollViews); - }, - - @on("willClearRender") - _cleanUpPollViews() { - if (this.get("pollViews")) { - _.forEach(this.get("pollViews"), v => v.destroy()); - } - } - }); + initialize() { + withPluginApi('0.1', initializePolls); } }; diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 index e8d8e8dbf59..38789579c18 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/d-editor-test.js.es6 b/test/javascripts/components/d-editor-test.js.es6 index ecb486f3a52..d8985c19c71 100644 --- a/test/javascripts/components/d-editor-test.js.es6 +++ b/test/javascripts/components/d-editor-test.js.es6 @@ -1,5 +1,5 @@ import componentTest from 'helpers/component-test'; -import { onToolbarCreate } from 'discourse/components/d-editor'; +import { withPluginApi } from 'discourse/lib/plugin-api'; moduleForComponent('d-editor', {integration: true}); @@ -540,12 +540,14 @@ componentTest('emoji', { template: '{{d-editor value=value}}', setup() { // Test adding a custom button - onToolbarCreate(toolbar => { - toolbar.addButton({ - id: 'emoji', - group: 'extras', - icon: 'smile-o', - action: 'emoji' + withPluginApi('0.1', api => { + api.onToolbarCreate(toolbar => { + toolbar.addButton({ + id: 'emoji', + group: 'extras', + icon: 'smile-o', + action: 'emoji' + }); }); }); this.set('value', 'hello world.'); 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 a027bb660fa..00000000000 --- 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 13fdc144eb8..48f0bcdbd02 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/ember/resolver-test.js.es6 b/test/javascripts/ember/resolver-test.js.es6 index 948add12d8b..1e5ad1ea593 100644 --- a/test/javascripts/ember/resolver-test.js.es6 +++ b/test/javascripts/ember/resolver-test.js.es6 @@ -1,7 +1,7 @@ import DiscourseResolver from 'discourse/ember/resolver'; -var originalTemplates, originalMobileViewFlag; -var resolver = DiscourseResolver.create(); +let originalTemplates; +let resolver; function lookupTemplate(name, expectedTemplate, message) { var parseName = resolver.parseName(name); @@ -20,13 +20,11 @@ module("lib:resolver", { originalTemplates = Ember.TEMPLATES; Ember.TEMPLATES = {}; - originalMobileViewFlag = Discourse.Mobile.mobileView; - Discourse.Mobile.mobileView = false; + resolver = DiscourseResolver.create(); }, teardown: function() { Ember.TEMPLATES = originalTemplates; - Discourse.Mobile.mobileView = originalMobileViewFlag; } }); @@ -92,7 +90,7 @@ test("resolves mobile templates to 'mobile/' namespace", function() { "baz" ]); - Discourse.Mobile.mobileView = true; + resolver.mobileView = true; lookupTemplate("template:foo", "mobile/foo", "finding mobile version even if normal one is not present"); lookupTemplate("template:bar", "mobile/bar", "preferring mobile version when both mobile and normal versions are present"); diff --git a/test/javascripts/fixtures/site-fixtures.js.es6 b/test/javascripts/fixtures/site-fixtures.js.es6 index 9e29ee832f9..81ce4b86975 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 3ff28d3c94b..0fd3c8d6b2c 100644 --- a/test/javascripts/helpers/component-test.js.es6 +++ b/test/javascripts/helpers/component-test.js.es6 @@ -6,20 +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.container.injection('component', 'site', 'site: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 fca63e935bf..0aaad1ff901 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 44812f7ec68..c6905856612 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 00000000000..d30170ff1db --- /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 41098560adf..0b9a7d3dfaa 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 77365775004..1f881df1209 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 08f9256f6ff..d8ca4fe8601 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 00000000000..17cb172c606 --- /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 00000000000..55c4152cb28 --- /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 00000000000..396ae206c0e --- /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().toString() }), + ]; + }, + + 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 00000000000..5c853c0dd65 --- /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 00000000000..5e6782625a2 --- /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/app/assets/javascripts/discourse/lib/highlight.js b/vendor/assets/javascripts/highlight.js similarity index 100% rename from app/assets/javascripts/discourse/lib/highlight.js rename to vendor/assets/javascripts/highlight.js diff --git a/vendor/assets/javascripts/jquery.debug.js b/vendor/assets/javascripts/jquery.debug.js index 79d631ff463..1e0ba997403 100644 --- a/vendor/assets/javascripts/jquery.debug.js +++ b/vendor/assets/javascripts/jquery.debug.js @@ -1,15 +1,15 @@ /*! - * jQuery JavaScript Library v2.1.3 + * jQuery JavaScript Library v2.2.0 * http://jquery.com/ * * Includes Sizzle.js * http://sizzlejs.com/ * - * Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors + * Copyright jQuery Foundation and other contributors * Released under the MIT license * http://jquery.org/license * - * Date: 2014-12-18T15:11Z + * Date: 2016-01-08T20:02Z */ (function( global, factory ) { @@ -41,10 +41,11 @@ // Can't be in strict mode, several libs including ASP.NET trace // the stack via arguments.caller.callee and Firefox dies if // you try to trace through "use strict" call chains. (#13335) -// - +//"use strict"; var arr = []; +var document = window.document; + var slice = arr.slice; var concat = arr.concat; @@ -64,13 +65,11 @@ var support = {}; var - // Use the correct document accordingly with window argument (sandbox) - document = window.document, - - version = "2.1.3", + version = "2.2.0", // Define a local copy of jQuery jQuery = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' // Need init if jQuery is called (just allow error to be thrown if not included) return new jQuery.fn.init( selector, context ); @@ -90,6 +89,7 @@ var }; jQuery.fn = jQuery.prototype = { + // The current version of jQuery being used jquery: version, @@ -133,16 +133,14 @@ jQuery.fn = jQuery.prototype = { }, // Execute a callback for every element in the matched set. - // (You can seed the arguments with an array of args, but this is - // only used internally.) - each: function( callback, args ) { - return jQuery.each( this, callback, args ); + each: function( callback ) { + return jQuery.each( this, callback ); }, map: function( callback ) { - return this.pushStack( jQuery.map(this, function( elem, i ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { return callback.call( elem, i, elem ); - })); + } ) ); }, slice: function() { @@ -160,11 +158,11 @@ jQuery.fn = jQuery.prototype = { eq: function( i ) { var len = this.length, j = +i + ( i < 0 ? len : 0 ); - return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); }, end: function() { - return this.prevObject || this.constructor(null); + return this.prevObject || this.constructor(); }, // For internal use only. @@ -176,7 +174,7 @@ jQuery.fn = jQuery.prototype = { jQuery.extend = jQuery.fn.extend = function() { var options, name, src, copy, copyIsArray, clone, - target = arguments[0] || {}, + target = arguments[ 0 ] || {}, i = 1, length = arguments.length, deep = false; @@ -191,7 +189,7 @@ jQuery.extend = jQuery.fn.extend = function() { } // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !jQuery.isFunction(target) ) { + if ( typeof target !== "object" && !jQuery.isFunction( target ) ) { target = {}; } @@ -202,8 +200,10 @@ jQuery.extend = jQuery.fn.extend = function() { } for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) != null ) { + if ( ( options = arguments[ i ] ) != null ) { + // Extend the base object for ( name in options ) { src = target[ name ]; @@ -215,13 +215,15 @@ jQuery.extend = jQuery.fn.extend = function() { } // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = jQuery.isArray( copy ) ) ) ) { + if ( copyIsArray ) { copyIsArray = false; - clone = src && jQuery.isArray(src) ? src : []; + clone = src && jQuery.isArray( src ) ? src : []; } else { - clone = src && jQuery.isPlainObject(src) ? src : {}; + clone = src && jQuery.isPlainObject( src ) ? src : {}; } // Never move original objects, clone them @@ -239,7 +241,8 @@ jQuery.extend = jQuery.fn.extend = function() { return target; }; -jQuery.extend({ +jQuery.extend( { + // Unique for each copy of jQuery on the page expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), @@ -253,7 +256,7 @@ jQuery.extend({ noop: function() {}, isFunction: function( obj ) { - return jQuery.type(obj) === "function"; + return jQuery.type( obj ) === "function"; }, isArray: Array.isArray, @@ -263,14 +266,17 @@ jQuery.extend({ }, isNumeric: function( obj ) { + // parseFloat NaNs numeric-cast false positives (null|true|false|"") // ...but misinterprets leading-number strings, particularly hex literals ("0x...") // subtraction forces infinities to NaN // adding 1 corrects loss of precision from parseFloat (#15100) - return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0; + var realStringObj = obj && obj.toString(); + return !jQuery.isArray( obj ) && ( realStringObj - parseFloat( realStringObj ) + 1 ) >= 0; }, isPlainObject: function( obj ) { + // Not plain objects: // - Any object or value whose internal [[Class]] property is not "[object Object]" // - DOM nodes @@ -301,9 +307,10 @@ jQuery.extend({ if ( obj == null ) { return obj + ""; } + // Support: Android<4.0, iOS<6 (functionish RegExp) return typeof obj === "object" || typeof obj === "function" ? - class2type[ toString.call(obj) ] || "object" : + class2type[ toString.call( obj ) ] || "object" : typeof obj; }, @@ -315,16 +322,19 @@ jQuery.extend({ code = jQuery.trim( code ); if ( code ) { + // If the code includes a valid, prologue position // strict mode pragma, execute code by injecting a // script tag into the document. - if ( code.indexOf("use strict") === 1 ) { - script = document.createElement("script"); + if ( code.indexOf( "use strict" ) === 1 ) { + script = document.createElement( "script" ); script.text = code; document.head.appendChild( script ).parentNode.removeChild( script ); } else { - // Otherwise, avoid the DOM node creation, insertion - // and removal by using an indirect global eval + + // Otherwise, avoid the DOM node creation, insertion + // and removal by using an indirect global eval + indirect( code ); } } @@ -341,49 +351,20 @@ jQuery.extend({ return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); }, - // args is for internal usage only - each: function( obj, callback, args ) { - var value, - i = 0, - length = obj.length, - isArray = isArraylike( obj ); + each: function( obj, callback ) { + var length, i = 0; - if ( args ) { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; } } - - // A special, fast, case for the most common use of each } else { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; } } } @@ -403,7 +384,7 @@ jQuery.extend({ var ret = results || []; if ( arr != null ) { - if ( isArraylike( Object(arr) ) ) { + if ( isArrayLike( Object( arr ) ) ) { jQuery.merge( ret, typeof arr === "string" ? [ arr ] : arr @@ -455,14 +436,13 @@ jQuery.extend({ // arg is for internal usage only map: function( elems, callback, arg ) { - var value, + var length, value, i = 0, - length = elems.length, - isArray = isArraylike( elems ), ret = []; // Go through the array, translating each of the items to their new values - if ( isArray ) { + if ( isArrayLike( elems ) ) { + length = elems.length; for ( ; i < length; i++ ) { value = callback( elems[ i ], i, arg ); @@ -523,38 +503,50 @@ jQuery.extend({ // jQuery.support is not used in Core but other projects attach their // properties to it so it needs to exist. support: support -}); +} ); + +// JSHint would error on this code due to the Symbol not being defined in ES5. +// Defining this global in .jshintrc would create a danger of using the global +// unguarded in another place, it seems safer to just disable JSHint for these +// three lines. +/* jshint ignore: start */ +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} +/* jshint ignore: end */ // Populate the class2type map -jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), +function( i, name ) { class2type[ "[object " + name + "]" ] = name.toLowerCase(); -}); +} ); -function isArraylike( obj ) { - var length = obj.length, +function isArrayLike( obj ) { + + // Support: iOS 8.2 (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, type = jQuery.type( obj ); if ( type === "function" || jQuery.isWindow( obj ) ) { return false; } - if ( obj.nodeType === 1 && length ) { - return true; - } - return type === "array" || length === 0 || typeof length === "number" && length > 0 && ( length - 1 ) in obj; } var Sizzle = /*! - * Sizzle CSS Selector Engine v2.2.0-pre + * Sizzle CSS Selector Engine v2.2.1 * http://sizzlejs.com/ * - * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors + * Copyright jQuery Foundation and other contributors * Released under the MIT license * http://jquery.org/license * - * Date: 2014-12-16 + * Date: 2015-10-17 */ (function( window ) { @@ -622,25 +614,21 @@ var i, // Regular expressions - // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace + // http://www.w3.org/TR/css3-selectors/#whitespace whitespace = "[\\x20\\t\\r\\n\\f]", - // http://www.w3.org/TR/css3-syntax/#characters - characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", - // Loosely modeled on CSS identifier characters - // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors - // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier - identifier = characterEncoding.replace( "w", "w#" ), + // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors - attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace + + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + // Operator (capture 2) "*([*^$|!~]?=)" + whitespace + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + "*\\]", - pseudos = ":(" + characterEncoding + ")(?:\\((" + + pseudos = ":(" + identifier + ")(?:\\((" + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: // 1. quoted (capture 3; capture 4 or capture 5) "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + @@ -663,9 +651,9 @@ var i, ridentifier = new RegExp( "^" + identifier + "$" ), matchExpr = { - "ID": new RegExp( "^#(" + characterEncoding + ")" ), - "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), - "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), "ATTR": new RegExp( "^" + attributes ), "PSEUDO": new RegExp( "^" + pseudos ), "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + @@ -743,103 +731,129 @@ try { } function Sizzle( selector, context, results, seed ) { - var match, elem, m, nodeType, - // QSA vars - i, groups, old, nid, newContext, newSelector; + var m, i, elem, nid, nidselect, match, groups, newSelector, + newContext = context && context.ownerDocument, - if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { - setDocument( context ); - } + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; - context = context || document; results = results || []; - nodeType = context.nodeType; + // Return early from calls with invalid selector or context if ( typeof selector !== "string" || !selector || nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { return results; } - if ( !seed && documentIsHTML ) { + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + + // ID selector + if ( (m = match[1]) ) { + + // Document context + if ( nodeType === 9 ) { + if ( (elem = context.getElementById( m )) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && (elem = newContext.getElementById( m )) && + contains( context, elem ) && + elem.id === m ) { - // Try to shortcut find operations when possible (e.g., not under DocumentFragment) - if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { - // Speed-up: Sizzle("#ID") - if ( (m = match[1]) ) { - if ( nodeType === 9 ) { - elem = context.getElementById( m ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document (jQuery #6963) - if ( elem && elem.parentNode ) { - // Handle the case where IE, Opera, and Webkit return items - // by name instead of ID - if ( elem.id === m ) { results.push( elem ); return results; } - } else { - return results; } - } else { - // Context is not a document - if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && - contains( context, elem ) && elem.id === m ) { - results.push( elem ); - return results; - } - } - // Speed-up: Sizzle("TAG") - } else if ( match[2] ) { - push.apply( results, context.getElementsByTagName( selector ) ); - return results; - - // Speed-up: Sizzle(".CLASS") - } else if ( (m = match[3]) && support.getElementsByClassName ) { - push.apply( results, context.getElementsByClassName( m ) ); - return results; - } - } - - // QSA path - if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { - nid = old = expando; - newContext = context; - newSelector = nodeType !== 1 && selector; - - // qSA works strangely on Element-rooted queries - // We can work around this by specifying an extra ID on the root - // and working up from there (Thanks to Andrew Dupont for the technique) - // IE 8 doesn't work on object elements - if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { - groups = tokenize( selector ); - - if ( (old = context.getAttribute("id")) ) { - nid = old.replace( rescape, "\\$&" ); - } else { - context.setAttribute( "id", nid ); - } - nid = "[id='" + nid + "'] "; - - i = groups.length; - while ( i-- ) { - groups[i] = nid + toSelector( groups[i] ); - } - newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context; - newSelector = groups.join(","); - } - - if ( newSelector ) { - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); + // Type selector + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); return results; - } catch(qsaError) { - } finally { - if ( !old ) { - context.removeAttribute("id"); + + // Class selector + } else if ( (m = match[3]) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !compilerCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + + if ( nodeType !== 1 ) { + newContext = context; + newSelector = selector; + + // qSA looks outside Element context, which is not what we want + // Thanks to Andrew Dupont for this workaround technique + // Support: IE <=8 + // Exclude object elements + } else if ( context.nodeName.toLowerCase() !== "object" ) { + + // Capture the context ID, setting it first if necessary + if ( (nid = context.getAttribute( "id" )) ) { + nid = nid.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", (nid = expando) ); + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + nidselect = ridentifier.test( nid ) ? "#" + nid : "[id='" + nid + "']"; + while ( i-- ) { + groups[i] = nidselect + " " + toSelector( groups[i] ); + } + newSelector = groups.join( "," ); + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + } + + if ( newSelector ) { + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } } } } @@ -852,7 +866,7 @@ function Sizzle( selector, context, results, seed ) { /** * Create key-value caches of limited size - * @returns {Function(string, Object)} Returns the Object data after storing it on itself with + * @returns {function(string, object)} Returns the Object data after storing it on itself with * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) * deleting the oldest entry */ @@ -907,7 +921,7 @@ function assert( fn ) { */ function addHandle( attrs, handler ) { var arr = attrs.split("|"), - i = attrs.length; + i = arr.length; while ( i-- ) { Expr.attrHandle[ arr[i] ] = handler; @@ -1020,33 +1034,29 @@ setDocument = Sizzle.setDocument = function( node ) { var hasCompare, parent, doc = node ? node.ownerDocument || node : preferredDoc; - // If no document and documentElement is available, return + // Return early if doc is invalid or already selected if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { return document; } - // Set our document + // Update global variables document = doc; - docElem = doc.documentElement; - parent = doc.defaultView; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); - // Support: IE>8 - // If iframe document is assigned to "document" variable and if iframe has been reloaded, - // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936 - // IE6-8 do not support the defaultView property so parent will be undefined - if ( parent && parent !== parent.top ) { - // IE11 does not have attachEvent, so all must suffer + // Support: IE 9-11, Edge + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + if ( (parent = document.defaultView) && parent.top !== parent ) { + // Support: IE 11 if ( parent.addEventListener ) { parent.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only } else if ( parent.attachEvent ) { parent.attachEvent( "onunload", unloadHandler ); } } - /* Support tests - ---------------------------------------------------------------------- */ - documentIsHTML = !isXML( doc ); - /* Attributes ---------------------------------------------------------------------- */ @@ -1063,12 +1073,12 @@ setDocument = Sizzle.setDocument = function( node ) { // Check if getElementsByTagName("*") returns only elements support.getElementsByTagName = assert(function( div ) { - div.appendChild( doc.createComment("") ); + div.appendChild( document.createComment("") ); return !div.getElementsByTagName("*").length; }); // Support: IE<9 - support.getElementsByClassName = rnative.test( doc.getElementsByClassName ); + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); // Support: IE<10 // Check if getElementById returns elements by name @@ -1076,7 +1086,7 @@ setDocument = Sizzle.setDocument = function( node ) { // so use a roundabout getElementsByName test support.getById = assert(function( div ) { docElem.appendChild( div ).id = expando; - return !doc.getElementsByName || !doc.getElementsByName( expando ).length; + return !document.getElementsByName || !document.getElementsByName( expando ).length; }); // ID find and filter @@ -1084,9 +1094,7 @@ setDocument = Sizzle.setDocument = function( node ) { Expr.find["ID"] = function( id, context ) { if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { var m = context.getElementById( id ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - return m && m.parentNode ? [ m ] : []; + return m ? [ m ] : []; } }; Expr.filter["ID"] = function( id ) { @@ -1103,7 +1111,8 @@ setDocument = Sizzle.setDocument = function( node ) { Expr.filter["ID"] = function( id ) { var attrId = id.replace( runescape, funescape ); return function( elem ) { - var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode("id"); return node && node.value === attrId; }; }; @@ -1143,7 +1152,7 @@ setDocument = Sizzle.setDocument = function( node ) { // Class Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { - if ( documentIsHTML ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { return context.getElementsByClassName( className ); } }; @@ -1163,7 +1172,7 @@ setDocument = Sizzle.setDocument = function( node ) { // See http://bugs.jquery.com/ticket/13378 rbuggyQSA = []; - if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) { + if ( (support.qsa = rnative.test( document.querySelectorAll )) ) { // Build QSA regex // Regex strategy adopted from Diego Perini assert(function( div ) { @@ -1173,7 +1182,7 @@ setDocument = Sizzle.setDocument = function( node ) { // since its presence should be enough // http://bugs.jquery.com/ticket/12359 docElem.appendChild( div ).innerHTML = "" + - "" + ""; // Support: IE8, Opera 11-12.16 @@ -1190,7 +1199,7 @@ setDocument = Sizzle.setDocument = function( node ) { rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); } - // Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+ + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { rbuggyQSA.push("~="); } @@ -1213,7 +1222,7 @@ setDocument = Sizzle.setDocument = function( node ) { assert(function( div ) { // Support: Windows 8 Native Apps // The type and name attributes are restricted during .innerHTML assignment - var input = doc.createElement("input"); + var input = document.createElement("input"); input.setAttribute( "type", "hidden" ); div.appendChild( input ).setAttribute( "name", "D" ); @@ -1261,7 +1270,7 @@ setDocument = Sizzle.setDocument = function( node ) { hasCompare = rnative.test( docElem.compareDocumentPosition ); // Element contains another - // Purposefully does not implement inclusive descendent + // Purposefully self-exclusive // As in, an element does not contain itself contains = hasCompare || rnative.test( docElem.contains ) ? function( a, b ) { @@ -1315,10 +1324,10 @@ setDocument = Sizzle.setDocument = function( node ) { (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { // Choose the first element that is related to our preferred document - if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { return -1; } - if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { return 1; } @@ -1346,8 +1355,8 @@ setDocument = Sizzle.setDocument = function( node ) { // Parentless nodes are either documents or disconnected if ( !aup || !bup ) { - return a === doc ? -1 : - b === doc ? 1 : + return a === document ? -1 : + b === document ? 1 : aup ? -1 : bup ? 1 : sortInput ? @@ -1384,7 +1393,7 @@ setDocument = Sizzle.setDocument = function( node ) { 0; }; - return doc; + return document; }; Sizzle.matches = function( expr, elements ) { @@ -1401,6 +1410,7 @@ Sizzle.matchesSelector = function( elem, expr ) { expr = expr.replace( rattributeQuotes, "='$1']" ); if ( support.matchesSelector && documentIsHTML && + !compilerCache[ expr + " " ] && ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { @@ -1674,11 +1684,12 @@ Expr = Sizzle.selectors = { } : function( elem, context, xml ) { - var cache, outerCache, node, diff, nodeIndex, start, + var cache, uniqueCache, outerCache, node, nodeIndex, start, dir = simple !== forward ? "nextSibling" : "previousSibling", parent = elem.parentNode, name = ofType && elem.nodeName.toLowerCase(), - useCache = !xml && !ofType; + useCache = !xml && !ofType, + diff = false; if ( parent ) { @@ -1687,7 +1698,10 @@ Expr = Sizzle.selectors = { while ( dir ) { node = elem; while ( (node = node[ dir ]) ) { - if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { + if ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) { + return false; } } @@ -1701,11 +1715,21 @@ Expr = Sizzle.selectors = { // non-xml :nth-child(...) stores cache data on `parent` if ( forward && useCache ) { + // Seek `elem` from a previously-cached index - outerCache = parent[ expando ] || (parent[ expando ] = {}); - cache = outerCache[ type ] || []; - nodeIndex = cache[0] === dirruns && cache[1]; - diff = cache[0] === dirruns && cache[2]; + + // ...in a gzip-friendly way + node = parent; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex && cache[ 2 ]; node = nodeIndex && parent.childNodes[ nodeIndex ]; while ( (node = ++nodeIndex && node && node[ dir ] || @@ -1715,29 +1739,55 @@ Expr = Sizzle.selectors = { // When found, cache indexes on `parent` and break if ( node.nodeType === 1 && ++diff && node === elem ) { - outerCache[ type ] = [ dirruns, nodeIndex, diff ]; + uniqueCache[ type ] = [ dirruns, nodeIndex, diff ]; break; } } - // Use previously-cached element index if available - } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { - diff = cache[1]; - - // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) } else { - // Use the same loop as above to seek `elem` from the start - while ( (node = ++nodeIndex && node && node[ dir ] || - (diff = nodeIndex = 0) || start.pop()) ) { + // Use previously-cached element index if available + if ( useCache ) { + // ...in a gzip-friendly way + node = elem; + outerCache = node[ expando ] || (node[ expando ] = {}); - if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { - // Cache the index of each encountered element - if ( useCache ) { - (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; - } + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); - if ( node === elem ) { - break; + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex; + } + + // xml :nth-child(...) + // or :nth-last-child(...) or :nth(-last)?-of-type(...) + if ( diff === false ) { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) && + ++diff ) { + + // Cache the index of each encountered element + if ( useCache ) { + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + uniqueCache[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } } } } @@ -2099,10 +2149,10 @@ function addCombinator( matcher, combinator, base ) { // Check against all ancestor/preceding elements function( elem, context, xml ) { - var oldCache, outerCache, + var oldCache, uniqueCache, outerCache, newCache = [ dirruns, doneName ]; - // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching + // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching if ( xml ) { while ( (elem = elem[ dir ]) ) { if ( elem.nodeType === 1 || checkNonElements ) { @@ -2115,14 +2165,19 @@ function addCombinator( matcher, combinator, base ) { while ( (elem = elem[ dir ]) ) { if ( elem.nodeType === 1 || checkNonElements ) { outerCache = elem[ expando ] || (elem[ expando ] = {}); - if ( (oldCache = outerCache[ dir ]) && + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {}); + + if ( (oldCache = uniqueCache[ dir ]) && oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { // Assign to newCache so results back-propagate to previous elements return (newCache[ 2 ] = oldCache[ 2 ]); } else { // Reuse newcache so results back-propagate to previous elements - outerCache[ dir ] = newCache; + uniqueCache[ dir ] = newCache; // A match means we're done; a fail means we have to keep checking if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { @@ -2347,18 +2402,21 @@ function matcherFromGroupMatchers( elementMatchers, setMatchers ) { len = elems.length; if ( outermost ) { - outermostContext = context !== document && context; + outermostContext = context === document || context || outermost; } // Add elements passing elementMatchers directly to results - // Keep `i` a string if there are no elements so `matchedCount` will be "00" below // Support: IE<9, Safari // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id for ( ; i !== len && (elem = elems[i]) != null; i++ ) { if ( byElement && elem ) { j = 0; + if ( !context && elem.ownerDocument !== document ) { + setDocument( elem ); + xml = !documentIsHTML; + } while ( (matcher = elementMatchers[j++]) ) { - if ( matcher( elem, context, xml ) ) { + if ( matcher( elem, context || document, xml) ) { results.push( elem ); break; } @@ -2382,8 +2440,17 @@ function matcherFromGroupMatchers( elementMatchers, setMatchers ) { } } - // Apply set filters to unmatched elements + // `i` is now the count of elements visited above, and adding it to `matchedCount` + // makes the latter nonnegative. matchedCount += i; + + // Apply set filters to unmatched elements + // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount` + // equals `i`), unless we didn't visit _any_ elements in the above loop because we have + // no element matchers and no seed. + // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that + // case, which will result in a "00" `matchedCount` that differs from `i` but is also + // numerically zero. if ( bySet && i !== matchedCount ) { j = 0; while ( (matcher = setMatchers[j++]) ) { @@ -2475,10 +2542,11 @@ select = Sizzle.select = function( selector, context, results, seed ) { results = results || []; - // Try to minimize operations if there is no seed and only one group + // Try to minimize operations if there is only one selector in the list and no seed + // (the latter of which guarantees us context) if ( match.length === 1 ) { - // Take a shortcut and set the context if the root selector is an ID + // Reduce context if the leading compound selector is an ID tokens = match[0] = match[0].slice( 0 ); if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && support.getById && context.nodeType === 9 && documentIsHTML && @@ -2533,7 +2601,7 @@ select = Sizzle.select = function( selector, context, results, seed ) { context, !documentIsHTML, results, - rsibling.test( selector ) && testContext( context.parentNode ) || context + !context || rsibling.test( selector ) && testContext( context.parentNode ) || context ); return results; }; @@ -2609,17 +2677,46 @@ return Sizzle; jQuery.find = Sizzle; jQuery.expr = Sizzle.selectors; -jQuery.expr[":"] = jQuery.expr.pseudos; -jQuery.unique = Sizzle.uniqueSort; +jQuery.expr[ ":" ] = jQuery.expr.pseudos; +jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort; jQuery.text = Sizzle.getText; jQuery.isXMLDoc = Sizzle.isXML; jQuery.contains = Sizzle.contains; +var dir = function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; +}; + + +var siblings = function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; +}; + + var rneedsContext = jQuery.expr.match.needsContext; -var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/); +var rsingleTag = ( /^<([\w-]+)\s*\/?>(?:<\/\1>|)$/ ); @@ -2631,14 +2728,14 @@ function winnow( elements, qualifier, not ) { return jQuery.grep( elements, function( elem, i ) { /* jshint -W018 */ return !!qualifier.call( elem, i, elem ) !== not; - }); + } ); } if ( qualifier.nodeType ) { return jQuery.grep( elements, function( elem ) { return ( elem === qualifier ) !== not; - }); + } ); } @@ -2651,8 +2748,8 @@ function winnow( elements, qualifier, not ) { } return jQuery.grep( elements, function( elem ) { - return ( indexOf.call( qualifier, elem ) >= 0 ) !== not; - }); + return ( indexOf.call( qualifier, elem ) > -1 ) !== not; + } ); } jQuery.filter = function( expr, elems, not ) { @@ -2666,10 +2763,10 @@ jQuery.filter = function( expr, elems, not ) { jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { return elem.nodeType === 1; - })); + } ) ); }; -jQuery.fn.extend({ +jQuery.fn.extend( { find: function( selector ) { var i, len = this.length, @@ -2677,13 +2774,13 @@ jQuery.fn.extend({ self = this; if ( typeof selector !== "string" ) { - return this.pushStack( jQuery( selector ).filter(function() { + return this.pushStack( jQuery( selector ).filter( function() { for ( i = 0; i < len; i++ ) { if ( jQuery.contains( self[ i ], this ) ) { return true; } } - }) ); + } ) ); } for ( i = 0; i < len; i++ ) { @@ -2696,10 +2793,10 @@ jQuery.fn.extend({ return ret; }, filter: function( selector ) { - return this.pushStack( winnow(this, selector || [], false) ); + return this.pushStack( winnow( this, selector || [], false ) ); }, not: function( selector ) { - return this.pushStack( winnow(this, selector || [], true) ); + return this.pushStack( winnow( this, selector || [], true ) ); }, is: function( selector ) { return !!winnow( @@ -2713,7 +2810,7 @@ jQuery.fn.extend({ false ).length; } -}); +} ); // Initialize a jQuery object @@ -2727,7 +2824,7 @@ var rootjQuery, // Strict HTML recognition (#11290: must start with <) rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, - init = jQuery.fn.init = function( selector, context ) { + init = jQuery.fn.init = function( selector, context, root ) { var match, elem; // HANDLE: $(""), $(null), $(undefined), $(false) @@ -2735,9 +2832,16 @@ var rootjQuery, return this; } + // Method init() accepts an alternate rootjQuery + // so migrate can support jQuery.sub (gh-2101) + root = root || rootjQuery; + // Handle HTML strings if ( typeof selector === "string" ) { - if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) { + if ( selector[ 0 ] === "<" && + selector[ selector.length - 1 ] === ">" && + selector.length >= 3 ) { + // Assume that strings that start and end with <> are HTML and skip the regex check match = [ null, selector, null ]; @@ -2746,23 +2850,24 @@ var rootjQuery, } // Match html or make sure no context is specified for #id - if ( match && (match[1] || !context) ) { + if ( match && ( match[ 1 ] || !context ) ) { // HANDLE: $(html) -> $(array) - if ( match[1] ) { - context = context instanceof jQuery ? context[0] : context; + if ( match[ 1 ] ) { + context = context instanceof jQuery ? context[ 0 ] : context; // Option to run scripts is true for back-compat // Intentionally let the error be thrown if parseHTML is not present jQuery.merge( this, jQuery.parseHTML( - match[1], + match[ 1 ], context && context.nodeType ? context.ownerDocument || context : document, true ) ); // HANDLE: $(html, props) - if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { + if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) { for ( match in context ) { + // Properties of context are called as methods if possible if ( jQuery.isFunction( this[ match ] ) ) { this[ match ]( context[ match ] ); @@ -2778,14 +2883,15 @@ var rootjQuery, // HANDLE: $(#id) } else { - elem = document.getElementById( match[2] ); + elem = document.getElementById( match[ 2 ] ); // Support: Blackberry 4.6 // gEBID returns nodes no longer in the document (#6963) if ( elem && elem.parentNode ) { + // Inject the element directly into the jQuery object this.length = 1; - this[0] = elem; + this[ 0 ] = elem; } this.context = document; @@ -2795,7 +2901,7 @@ var rootjQuery, // HANDLE: $(expr, $(...)) } else if ( !context || context.jquery ) { - return ( context || rootjQuery ).find( selector ); + return ( context || root ).find( selector ); // HANDLE: $(expr, context) // (which is just equivalent to: $(context).find(expr) @@ -2805,15 +2911,16 @@ var rootjQuery, // HANDLE: $(DOMElement) } else if ( selector.nodeType ) { - this.context = this[0] = selector; + this.context = this[ 0 ] = selector; this.length = 1; return this; // HANDLE: $(function) // Shortcut for document ready } else if ( jQuery.isFunction( selector ) ) { - return typeof rootjQuery.ready !== "undefined" ? - rootjQuery.ready( selector ) : + return root.ready !== undefined ? + root.ready( selector ) : + // Execute immediately if ready is not present selector( jQuery ); } @@ -2834,6 +2941,7 @@ rootjQuery = jQuery( document ); var rparentsprev = /^(?:parents|prev(?:Until|All))/, + // Methods guaranteed to produce a unique set when starting from a unique set guaranteedUnique = { children: true, @@ -2842,48 +2950,19 @@ var rparentsprev = /^(?:parents|prev(?:Until|All))/, prev: true }; -jQuery.extend({ - dir: function( elem, dir, until ) { - var matched = [], - truncate = until !== undefined; - - while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) { - if ( elem.nodeType === 1 ) { - if ( truncate && jQuery( elem ).is( until ) ) { - break; - } - matched.push( elem ); - } - } - return matched; - }, - - sibling: function( n, elem ) { - var matched = []; - - for ( ; n; n = n.nextSibling ) { - if ( n.nodeType === 1 && n !== elem ) { - matched.push( n ); - } - } - - return matched; - } -}); - -jQuery.fn.extend({ +jQuery.fn.extend( { has: function( target ) { var targets = jQuery( target, this ), l = targets.length; - return this.filter(function() { + return this.filter( function() { var i = 0; for ( ; i < l; i++ ) { - if ( jQuery.contains( this, targets[i] ) ) { + if ( jQuery.contains( this, targets[ i ] ) ) { return true; } } - }); + } ); }, closest: function( selectors, context ) { @@ -2896,14 +2975,15 @@ jQuery.fn.extend({ 0; for ( ; i < l; i++ ) { - for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) { + for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) { + // Always skip document fragments - if ( cur.nodeType < 11 && (pos ? - pos.index(cur) > -1 : + if ( cur.nodeType < 11 && ( pos ? + pos.index( cur ) > -1 : // Don't pass non-elements to Sizzle cur.nodeType === 1 && - jQuery.find.matchesSelector(cur, selectors)) ) { + jQuery.find.matchesSelector( cur, selectors ) ) ) { matched.push( cur ); break; @@ -2911,7 +2991,7 @@ jQuery.fn.extend({ } } - return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched ); + return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched ); }, // Determine the position of an element within the set @@ -2937,7 +3017,7 @@ jQuery.fn.extend({ add: function( selector, context ) { return this.pushStack( - jQuery.unique( + jQuery.uniqueSort( jQuery.merge( this.get(), jQuery( selector, context ) ) ) ); @@ -2945,26 +3025,26 @@ jQuery.fn.extend({ addBack: function( selector ) { return this.add( selector == null ? - this.prevObject : this.prevObject.filter(selector) + this.prevObject : this.prevObject.filter( selector ) ); } -}); +} ); function sibling( cur, dir ) { - while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {} + while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {} return cur; } -jQuery.each({ +jQuery.each( { parent: function( elem ) { var parent = elem.parentNode; return parent && parent.nodeType !== 11 ? parent : null; }, parents: function( elem ) { - return jQuery.dir( elem, "parentNode" ); + return dir( elem, "parentNode" ); }, parentsUntil: function( elem, i, until ) { - return jQuery.dir( elem, "parentNode", until ); + return dir( elem, "parentNode", until ); }, next: function( elem ) { return sibling( elem, "nextSibling" ); @@ -2973,22 +3053,22 @@ jQuery.each({ return sibling( elem, "previousSibling" ); }, nextAll: function( elem ) { - return jQuery.dir( elem, "nextSibling" ); + return dir( elem, "nextSibling" ); }, prevAll: function( elem ) { - return jQuery.dir( elem, "previousSibling" ); + return dir( elem, "previousSibling" ); }, nextUntil: function( elem, i, until ) { - return jQuery.dir( elem, "nextSibling", until ); + return dir( elem, "nextSibling", until ); }, prevUntil: function( elem, i, until ) { - return jQuery.dir( elem, "previousSibling", until ); + return dir( elem, "previousSibling", until ); }, siblings: function( elem ) { - return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); + return siblings( ( elem.parentNode || {} ).firstChild, elem ); }, children: function( elem ) { - return jQuery.sibling( elem.firstChild ); + return siblings( elem.firstChild ); }, contents: function( elem ) { return elem.contentDocument || jQuery.merge( [], elem.childNodes ); @@ -3006,9 +3086,10 @@ jQuery.each({ } if ( this.length > 1 ) { + // Remove duplicates if ( !guaranteedUnique[ name ] ) { - jQuery.unique( matched ); + jQuery.uniqueSort( matched ); } // Reverse order for parents* and prev-derivatives @@ -3019,20 +3100,17 @@ jQuery.each({ return this.pushStack( matched ); }; -}); -var rnotwhite = (/\S+/g); +} ); +var rnotwhite = ( /\S+/g ); -// String to Object options format cache -var optionsCache = {}; - -// Convert String-formatted options into Object-formatted ones and store in cache +// Convert String-formatted options into Object-formatted ones function createOptions( options ) { - var object = optionsCache[ options ] = {}; + var object = {}; jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { object[ flag ] = true; - }); + } ); return object; } @@ -3063,156 +3141,186 @@ jQuery.Callbacks = function( options ) { // Convert options from String-formatted to Object-formatted if needed // (we check in cache first) options = typeof options === "string" ? - ( optionsCache[ options ] || createOptions( options ) ) : + createOptions( options ) : jQuery.extend( {}, options ); - var // Last fire value (for non-forgettable lists) + var // Flag to know if list is currently firing + firing, + + // Last fire value for non-forgettable lists memory, + // Flag to know if list was already fired fired, - // Flag to know if list is currently firing - firing, - // First callback to fire (used internally by add and fireWith) - firingStart, - // End of the loop when firing - firingLength, - // Index of currently firing callback (modified by remove if needed) - firingIndex, + + // Flag to prevent firing + locked, + // Actual callback list list = [], - // Stack of fire calls for repeatable lists - stack = !options.once && [], + + // Queue of execution data for repeatable lists + queue = [], + + // Index of currently firing callback (modified by add/remove as needed) + firingIndex = -1, + // Fire callbacks - fire = function( data ) { - memory = options.memory && data; - fired = true; - firingIndex = firingStart || 0; - firingStart = 0; - firingLength = list.length; - firing = true; - for ( ; list && firingIndex < firingLength; firingIndex++ ) { - if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { - memory = false; // To prevent further calls using add - break; + fire = function() { + + // Enforce single-firing + locked = options.once; + + // Execute callbacks for all pending executions, + // respecting firingIndex overrides and runtime changes + fired = firing = true; + for ( ; queue.length; firingIndex = -1 ) { + memory = queue.shift(); + while ( ++firingIndex < list.length ) { + + // Run callback and check for early termination + if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && + options.stopOnFalse ) { + + // Jump to end and forget the data so .add doesn't re-fire + firingIndex = list.length; + memory = false; + } } } + + // Forget the data if we're done with it + if ( !options.memory ) { + memory = false; + } + firing = false; - if ( list ) { - if ( stack ) { - if ( stack.length ) { - fire( stack.shift() ); - } - } else if ( memory ) { + + // Clean up if we're done firing for good + if ( locked ) { + + // Keep an empty list if we have data for future add calls + if ( memory ) { list = []; + + // Otherwise, this object is spent } else { - self.disable(); + list = ""; } } }, + // Actual Callbacks object self = { + // Add a callback or a collection of callbacks to the list add: function() { if ( list ) { - // First, we save the current length - var start = list.length; - (function add( args ) { + + // If we have memory from a past run, we should fire after adding + if ( memory && !firing ) { + firingIndex = list.length - 1; + queue.push( memory ); + } + + ( function add( args ) { jQuery.each( args, function( _, arg ) { - var type = jQuery.type( arg ); - if ( type === "function" ) { + if ( jQuery.isFunction( arg ) ) { if ( !options.unique || !self.has( arg ) ) { list.push( arg ); } - } else if ( arg && arg.length && type !== "string" ) { + } else if ( arg && arg.length && jQuery.type( arg ) !== "string" ) { + // Inspect recursively add( arg ); } - }); - })( arguments ); - // Do we need to add the callbacks to the - // current firing batch? - if ( firing ) { - firingLength = list.length; - // With memory, if we're not firing then - // we should call right away - } else if ( memory ) { - firingStart = start; - fire( memory ); + } ); + } )( arguments ); + + if ( memory && !firing ) { + fire(); } } return this; }, + // Remove a callback from the list remove: function() { - if ( list ) { - jQuery.each( arguments, function( _, arg ) { - var index; - while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { - list.splice( index, 1 ); - // Handle firing indexes - if ( firing ) { - if ( index <= firingLength ) { - firingLength--; - } - if ( index <= firingIndex ) { - firingIndex--; - } - } + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + + // Handle firing indexes + if ( index <= firingIndex ) { + firingIndex--; } - }); - } + } + } ); return this; }, + // Check if a given callback is in the list. // If no argument is given, return whether or not list has callbacks attached. has: function( fn ) { - return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); + return fn ? + jQuery.inArray( fn, list ) > -1 : + list.length > 0; }, + // Remove all callbacks from the list empty: function() { - list = []; - firingLength = 0; - return this; - }, - // Have the list do nothing anymore - disable: function() { - list = stack = memory = undefined; - return this; - }, - // Is it disabled? - disabled: function() { - return !list; - }, - // Lock the list in its current state - lock: function() { - stack = undefined; - if ( !memory ) { - self.disable(); + if ( list ) { + list = []; } return this; }, - // Is it locked? - locked: function() { - return !stack; + + // Disable .fire and .add + // Abort any current/pending executions + // Clear all callbacks and values + disable: function() { + locked = queue = []; + list = memory = ""; + return this; }, + disabled: function() { + return !list; + }, + + // Disable .fire + // Also disable .add unless we have memory (since it would have no effect) + // Abort any pending executions + lock: function() { + locked = queue = []; + if ( !memory ) { + list = memory = ""; + } + return this; + }, + locked: function() { + return !!locked; + }, + // Call all callbacks with the given context and arguments fireWith: function( context, args ) { - if ( list && ( !fired || stack ) ) { + if ( !locked ) { args = args || []; args = [ context, args.slice ? args.slice() : args ]; - if ( firing ) { - stack.push( args ); - } else { - fire( args ); + queue.push( args ); + if ( !firing ) { + fire(); } } return this; }, + // Call all the callbacks with the given arguments fire: function() { self.fireWith( this, arguments ); return this; }, + // To know if the callbacks have already been called at least once fired: function() { return !!fired; @@ -3223,14 +3331,15 @@ jQuery.Callbacks = function( options ) { }; -jQuery.extend({ +jQuery.extend( { Deferred: function( func ) { var tuples = [ + // action, add listener, listener list, final state - [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], - [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], - [ "notify", "progress", jQuery.Callbacks("memory") ] + [ "resolve", "done", jQuery.Callbacks( "once memory" ), "resolved" ], + [ "reject", "fail", jQuery.Callbacks( "once memory" ), "rejected" ], + [ "notify", "progress", jQuery.Callbacks( "memory" ) ] ], state = "pending", promise = { @@ -3243,25 +3352,30 @@ jQuery.extend({ }, then: function( /* fnDone, fnFail, fnProgress */ ) { var fns = arguments; - return jQuery.Deferred(function( newDefer ) { + return jQuery.Deferred( function( newDefer ) { jQuery.each( tuples, function( i, tuple ) { var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; + // deferred[ done | fail | progress ] for forwarding actions to newDefer - deferred[ tuple[1] ](function() { + deferred[ tuple[ 1 ] ]( function() { var returned = fn && fn.apply( this, arguments ); if ( returned && jQuery.isFunction( returned.promise ) ) { returned.promise() + .progress( newDefer.notify ) .done( newDefer.resolve ) - .fail( newDefer.reject ) - .progress( newDefer.notify ); + .fail( newDefer.reject ); } else { - newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); + newDefer[ tuple[ 0 ] + "With" ]( + this === promise ? newDefer.promise() : this, + fn ? [ returned ] : arguments + ); } - }); - }); + } ); + } ); fns = null; - }).promise(); + } ).promise(); }, + // Get a promise for this deferred // If obj is provided, the promise aspect is added to the object promise: function( obj ) { @@ -3279,11 +3393,12 @@ jQuery.extend({ stateString = tuple[ 3 ]; // promise[ done | fail | progress ] = list.add - promise[ tuple[1] ] = list.add; + promise[ tuple[ 1 ] ] = list.add; // Handle state if ( stateString ) { - list.add(function() { + list.add( function() { + // state = [ resolved | rejected ] state = stateString; @@ -3292,12 +3407,12 @@ jQuery.extend({ } // deferred[ resolve | reject | notify ] - deferred[ tuple[0] ] = function() { - deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); + deferred[ tuple[ 0 ] ] = function() { + deferred[ tuple[ 0 ] + "With" ]( this === deferred ? promise : this, arguments ); return this; }; - deferred[ tuple[0] + "With" ] = list.fireWith; - }); + deferred[ tuple[ 0 ] + "With" ] = list.fireWith; + } ); // Make the deferred a promise promise.promise( deferred ); @@ -3318,9 +3433,11 @@ jQuery.extend({ length = resolveValues.length, // the count of uncompleted subordinates - remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + remaining = length !== 1 || + ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, - // the master Deferred. If resolveValues consist of only a single Deferred, just use that. + // the master Deferred. + // If resolveValues consist of only a single Deferred, just use that. deferred = remaining === 1 ? subordinate : jQuery.Deferred(), // Update function for both resolve and progress values @@ -3346,9 +3463,9 @@ jQuery.extend({ for ( ; i < length; i++ ) { if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { resolveValues[ i ].promise() + .progress( updateFunc( i, progressContexts, progressValues ) ) .done( updateFunc( i, resolveContexts, resolveValues ) ) - .fail( deferred.reject ) - .progress( updateFunc( i, progressContexts, progressValues ) ); + .fail( deferred.reject ); } else { --remaining; } @@ -3362,20 +3479,22 @@ jQuery.extend({ return deferred.promise(); } -}); +} ); // The deferred used on DOM ready var readyList; jQuery.fn.ready = function( fn ) { + // Add the callback jQuery.ready.promise().done( fn ); return this; }; -jQuery.extend({ +jQuery.extend( { + // Is the DOM ready to be used? Set to true once it occurs. isReady: false, @@ -3417,14 +3536,14 @@ jQuery.extend({ jQuery( document ).off( "ready" ); } } -}); +} ); /** * The ready event handler and self cleanup method */ function completed() { - document.removeEventListener( "DOMContentLoaded", completed, false ); - window.removeEventListener( "load", completed, false ); + document.removeEventListener( "DOMContentLoaded", completed ); + window.removeEventListener( "load", completed ); jQuery.ready(); } @@ -3433,20 +3552,23 @@ jQuery.ready.promise = function( obj ) { readyList = jQuery.Deferred(); - // Catch cases where $(document).ready() is called after the browser event has already occurred. - // We once tried to use readyState "interactive" here, but it caused issues like the one - // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 - if ( document.readyState === "complete" ) { + // Catch cases where $(document).ready() is called + // after the browser event has already occurred. + // Support: IE9-10 only + // Older IE sometimes signals "interactive" too soon + if ( document.readyState === "complete" || + ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) { + // Handle it asynchronously to allow scripts the opportunity to delay ready - setTimeout( jQuery.ready ); + window.setTimeout( jQuery.ready ); } else { // Use the handy event callback - document.addEventListener( "DOMContentLoaded", completed, false ); + document.addEventListener( "DOMContentLoaded", completed ); // A fallback to window.onload, that will always work - window.addEventListener( "load", completed, false ); + window.addEventListener( "load", completed ); } } return readyList.promise( obj ); @@ -3460,7 +3582,7 @@ jQuery.ready.promise(); // Multifunctional method to get and set values of a collection // The value/s can optionally be executed if it's a function -var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) { +var access = function( elems, fn, key, value, chainable, emptyGet, raw ) { var i = 0, len = elems.length, bulk = key == null; @@ -3469,7 +3591,7 @@ var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGe if ( jQuery.type( key ) === "object" ) { chainable = true; for ( i in key ) { - jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); + access( elems, fn, i, key[ i ], true, emptyGet, raw ); } // Sets one value @@ -3481,6 +3603,7 @@ var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGe } if ( bulk ) { + // Bulk operations run against the entire set if ( raw ) { fn.call( elems, value ); @@ -3497,7 +3620,11 @@ var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGe if ( fn ) { for ( ; i < len; i++ ) { - fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); + fn( + elems[ i ], key, raw ? + value : + value.call( elems[ i ], i, fn( elems[ i ], key ) ) + ); } } } @@ -3508,14 +3635,10 @@ var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGe // Gets bulk ? fn.call( elems ) : - len ? fn( elems[0], key ) : emptyGet; + len ? fn( elems[ 0 ], key ) : emptyGet; }; +var acceptData = function( owner ) { - -/** - * Determines whether an object can have data - */ -jQuery.acceptData = function( owner ) { // Accepts only: // - Node // - Node.ELEMENT_NODE @@ -3527,66 +3650,79 @@ jQuery.acceptData = function( owner ) { }; -function Data() { - // Support: Android<4, - // Old WebKit does not have Object.preventExtensions/freeze method, - // return new empty object instead with no [[set]] accessor - Object.defineProperty( this.cache = {}, 0, { - get: function() { - return {}; - } - }); + +function Data() { this.expando = jQuery.expando + Data.uid++; } Data.uid = 1; -Data.accepts = jQuery.acceptData; Data.prototype = { - key: function( owner ) { + + register: function( owner, initial ) { + var value = initial || {}; + + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable, non-writable property + // configurability must be true to allow the property to be + // deleted with the delete operator + } else { + Object.defineProperty( owner, this.expando, { + value: value, + writable: true, + configurable: true + } ); + } + return owner[ this.expando ]; + }, + cache: function( owner ) { + // We can accept data for non-element nodes in modern browsers, // but we should not, see #8335. - // Always return the key for a frozen object. - if ( !Data.accepts( owner ) ) { - return 0; + // Always return an empty object. + if ( !acceptData( owner ) ) { + return {}; } - var descriptor = {}, - // Check if the owner object already has a cache key - unlock = owner[ this.expando ]; + // Check if the owner object already has a cache + var value = owner[ this.expando ]; // If not, create one - if ( !unlock ) { - unlock = Data.uid++; + if ( !value ) { + value = {}; - // Secure it in a non-enumerable, non-writable property - try { - descriptor[ this.expando ] = { value: unlock }; - Object.defineProperties( owner, descriptor ); + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return an empty object. + if ( acceptData( owner ) ) { - // Support: Android<4 - // Fallback to a less secure definition - } catch ( e ) { - descriptor[ this.expando ] = unlock; - jQuery.extend( owner, descriptor ); + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable property + // configurable must be true to allow the property to be + // deleted when data is removed + } else { + Object.defineProperty( owner, this.expando, { + value: value, + configurable: true + } ); + } } } - // Ensure the cache object - if ( !this.cache[ unlock ] ) { - this.cache[ unlock ] = {}; - } - - return unlock; + return value; }, set: function( owner, data, value ) { var prop, - // There may be an unlock assigned to this node, - // if there is no entry for this "owner", create one inline - // and set the unlock as though an owner entry had always existed - unlock = this.key( owner ), - cache = this.cache[ unlock ]; + cache = this.cache( owner ); // Handle: [ owner, key, value ] args if ( typeof data === "string" ) { @@ -3594,30 +3730,22 @@ Data.prototype = { // Handle: [ owner, { properties } ] args } else { - // Fresh assignments by object are shallow copied - if ( jQuery.isEmptyObject( cache ) ) { - jQuery.extend( this.cache[ unlock ], data ); - // Otherwise, copy the properties one-by-one to the cache object - } else { - for ( prop in data ) { - cache[ prop ] = data[ prop ]; - } + + // Copy the properties one-by-one to the cache object + for ( prop in data ) { + cache[ prop ] = data[ prop ]; } } return cache; }, get: function( owner, key ) { - // Either a valid cache is found, or will be created. - // New caches will be created and the unlock returned, - // allowing direct access to the newly created - // empty data object. A valid owner object must be provided. - var cache = this.cache[ this.key( owner ) ]; - return key === undefined ? - cache : cache[ key ]; + this.cache( owner ) : + owner[ this.expando ] && owner[ this.expando ][ key ]; }, access: function( owner, key, value ) { var stored; + // In cases where either: // // 1. No key was specified @@ -3630,15 +3758,15 @@ Data.prototype = { // 2. The data stored at the key // if ( key === undefined || - ((key && typeof key === "string") && value === undefined) ) { + ( ( key && typeof key === "string" ) && value === undefined ) ) { stored = this.get( owner, key ); return stored !== undefined ? - stored : this.get( owner, jQuery.camelCase(key) ); + stored : this.get( owner, jQuery.camelCase( key ) ); } - // [*]When the key is not a string, or both a key and value + // When the key is not a string, or both a key and value // are specified, set or extend (existing objects) with either: // // 1. An object of properties @@ -3652,15 +3780,20 @@ Data.prototype = { }, remove: function( owner, key ) { var i, name, camel, - unlock = this.key( owner ), - cache = this.cache[ unlock ]; + cache = owner[ this.expando ]; + + if ( cache === undefined ) { + return; + } if ( key === undefined ) { - this.cache[ unlock ] = {}; + this.register( owner ); } else { + // Support array or space separated string of keys if ( jQuery.isArray( key ) ) { + // If "name" is an array of keys... // When data is initially created, via ("key", "val") signature, // keys will be converted to camelCase. @@ -3670,10 +3803,12 @@ Data.prototype = { name = key.concat( key.map( jQuery.camelCase ) ); } else { camel = jQuery.camelCase( key ); + // Try the string as a key before any manipulation if ( key in cache ) { name = [ key, camel ]; } else { + // If a key with the spaces exists, use it. // Otherwise, create an array by matching non-whitespace name = camel; @@ -3683,25 +3818,34 @@ Data.prototype = { } i = name.length; + while ( i-- ) { delete cache[ name[ i ] ]; } } + + // Remove the expando if there's no more data + if ( key === undefined || jQuery.isEmptyObject( cache ) ) { + + // Support: Chrome <= 35-45+ + // Webkit & Blink performance suffers when deleting properties + // from DOM nodes, so set to undefined instead + // https://code.google.com/p/chromium/issues/detail?id=378607 + if ( owner.nodeType ) { + owner[ this.expando ] = undefined; + } else { + delete owner[ this.expando ]; + } + } }, hasData: function( owner ) { - return !jQuery.isEmptyObject( - this.cache[ owner[ this.expando ] ] || {} - ); - }, - discard: function( owner ) { - if ( owner[ this.expando ] ) { - delete this.cache[ owner[ this.expando ] ]; - } + var cache = owner[ this.expando ]; + return cache !== undefined && !jQuery.isEmptyObject( cache ); } }; -var data_priv = new Data(); +var dataPriv = new Data(); -var data_user = new Data(); +var dataUser = new Data(); @@ -3716,7 +3860,7 @@ var data_user = new Data(); // 6. Provide a clear path for implementation upgrade to WeakMap in 2014 var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, - rmultiDash = /([A-Z])/g; + rmultiDash = /[A-Z]/g; function dataAttr( elem, key, data ) { var name; @@ -3724,7 +3868,7 @@ function dataAttr( elem, key, data ) { // If nothing was found internally, try to fetch any // data from the HTML5 data-* attribute if ( data === undefined && elem.nodeType === 1 ) { - name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); + name = "data-" + key.replace( rmultiDash, "-$&" ).toLowerCase(); data = elem.getAttribute( name ); if ( typeof data === "string" ) { @@ -3732,14 +3876,15 @@ function dataAttr( elem, key, data ) { data = data === "true" ? true : data === "false" ? false : data === "null" ? null : + // Only convert to a number if it doesn't change the string +data + "" === data ? +data : rbrace.test( data ) ? jQuery.parseJSON( data ) : data; - } catch( e ) {} + } catch ( e ) {} // Make sure we set the data so it isn't changed later - data_user.set( elem, key, data ); + dataUser.set( elem, key, data ); } else { data = undefined; } @@ -3747,31 +3892,31 @@ function dataAttr( elem, key, data ) { return data; } -jQuery.extend({ +jQuery.extend( { hasData: function( elem ) { - return data_user.hasData( elem ) || data_priv.hasData( elem ); + return dataUser.hasData( elem ) || dataPriv.hasData( elem ); }, data: function( elem, name, data ) { - return data_user.access( elem, name, data ); + return dataUser.access( elem, name, data ); }, removeData: function( elem, name ) { - data_user.remove( elem, name ); + dataUser.remove( elem, name ); }, // TODO: Now that all calls to _data and _removeData have been replaced - // with direct calls to data_priv methods, these can be deprecated. + // with direct calls to dataPriv methods, these can be deprecated. _data: function( elem, name, data ) { - return data_priv.access( elem, name, data ); + return dataPriv.access( elem, name, data ); }, _removeData: function( elem, name ) { - data_priv.remove( elem, name ); + dataPriv.remove( elem, name ); } -}); +} ); -jQuery.fn.extend({ +jQuery.fn.extend( { data: function( key, value ) { var i, name, data, elem = this[ 0 ], @@ -3780,9 +3925,9 @@ jQuery.fn.extend({ // Gets all values if ( key === undefined ) { if ( this.length ) { - data = data_user.get( elem ); + data = dataUser.get( elem ); - if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) { + if ( elem.nodeType === 1 && !dataPriv.get( elem, "hasDataAttrs" ) ) { i = attrs.length; while ( i-- ) { @@ -3791,12 +3936,12 @@ jQuery.fn.extend({ if ( attrs[ i ] ) { name = attrs[ i ].name; if ( name.indexOf( "data-" ) === 0 ) { - name = jQuery.camelCase( name.slice(5) ); + name = jQuery.camelCase( name.slice( 5 ) ); dataAttr( elem, name, data[ name ] ); } } } - data_priv.set( elem, "hasDataAttrs", true ); + dataPriv.set( elem, "hasDataAttrs", true ); } } @@ -3805,14 +3950,13 @@ jQuery.fn.extend({ // Sets multiple values if ( typeof key === "object" ) { - return this.each(function() { - data_user.set( this, key ); - }); + return this.each( function() { + dataUser.set( this, key ); + } ); } return access( this, function( value ) { - var data, - camelKey = jQuery.camelCase( key ); + var data, camelKey; // The calling jQuery object (element matches) is not empty // (and therefore has an element appears at this[ 0 ]) and the @@ -3820,16 +3964,24 @@ jQuery.fn.extend({ // will result in `undefined` for elem = this[ 0 ] which will // throw an exception if an attempt to read a data cache is made. if ( elem && value === undefined ) { + // Attempt to get data from the cache // with the key as-is - data = data_user.get( elem, key ); + data = dataUser.get( elem, key ) || + + // Try to find dashed key if it exists (gh-2779) + // This is for 2.2.x only + dataUser.get( elem, key.replace( rmultiDash, "-$&" ).toLowerCase() ); + if ( data !== undefined ) { return data; } + camelKey = jQuery.camelCase( key ); + // Attempt to get data from the cache // with the key camelized - data = data_user.get( elem, camelKey ); + data = dataUser.get( elem, camelKey ); if ( data !== undefined ) { return data; } @@ -3846,46 +3998,48 @@ jQuery.fn.extend({ } // Set the data... - this.each(function() { + camelKey = jQuery.camelCase( key ); + this.each( function() { + // First, attempt to store a copy or reference of any // data that might've been store with a camelCased key. - var data = data_user.get( this, camelKey ); + var data = dataUser.get( this, camelKey ); // For HTML5 data-* attribute interop, we have to // store property names with dashes in a camelCase form. // This might not apply to all properties...* - data_user.set( this, camelKey, value ); + dataUser.set( this, camelKey, value ); // *... In the case of properties that might _actually_ // have dashes, we need to also store a copy of that // unchanged property. - if ( key.indexOf("-") !== -1 && data !== undefined ) { - data_user.set( this, key, value ); + if ( key.indexOf( "-" ) > -1 && data !== undefined ) { + dataUser.set( this, key, value ); } - }); + } ); }, null, value, arguments.length > 1, null, true ); }, removeData: function( key ) { - return this.each(function() { - data_user.remove( this, key ); - }); + return this.each( function() { + dataUser.remove( this, key ); + } ); } -}); +} ); -jQuery.extend({ +jQuery.extend( { queue: function( elem, type, data ) { var queue; if ( elem ) { type = ( type || "fx" ) + "queue"; - queue = data_priv.get( elem, type ); + queue = dataPriv.get( elem, type ); // Speed up dequeue by getting out quickly if this is just a lookup if ( data ) { if ( !queue || jQuery.isArray( data ) ) { - queue = data_priv.access( elem, type, jQuery.makeArray(data) ); + queue = dataPriv.access( elem, type, jQuery.makeArray( data ) ); } else { queue.push( data ); } @@ -3932,15 +4086,15 @@ jQuery.extend({ // Not public - generate a queueHooks object, or return the current one _queueHooks: function( elem, type ) { var key = type + "queueHooks"; - return data_priv.get( elem, key ) || data_priv.access( elem, key, { - empty: jQuery.Callbacks("once memory").add(function() { - data_priv.remove( elem, [ type + "queue", key ] ); - }) - }); + return dataPriv.get( elem, key ) || dataPriv.access( elem, key, { + empty: jQuery.Callbacks( "once memory" ).add( function() { + dataPriv.remove( elem, [ type + "queue", key ] ); + } ) + } ); } -}); +} ); -jQuery.fn.extend({ +jQuery.fn.extend( { queue: function( type, data ) { var setter = 2; @@ -3951,30 +4105,31 @@ jQuery.fn.extend({ } if ( arguments.length < setter ) { - return jQuery.queue( this[0], type ); + return jQuery.queue( this[ 0 ], type ); } return data === undefined ? this : - this.each(function() { + this.each( function() { var queue = jQuery.queue( this, type, data ); // Ensure a hooks for this queue jQuery._queueHooks( this, type ); - if ( type === "fx" && queue[0] !== "inprogress" ) { + if ( type === "fx" && queue[ 0 ] !== "inprogress" ) { jQuery.dequeue( this, type ); } - }); + } ); }, dequeue: function( type ) { - return this.each(function() { + return this.each( function() { jQuery.dequeue( this, type ); - }); + } ); }, clearQueue: function( type ) { return this.queue( type || "fx", [] ); }, + // Get a promise resolved when queues of a certain type // are emptied (fx is the type by default) promise: function( type, obj ) { @@ -3996,7 +4151,7 @@ jQuery.fn.extend({ type = type || "fx"; while ( i-- ) { - tmp = data_priv.get( elements[ i ], type + "queueHooks" ); + tmp = dataPriv.get( elements[ i ], type + "queueHooks" ); if ( tmp && tmp.empty ) { count++; tmp.empty.add( resolve ); @@ -4005,28 +4160,243 @@ jQuery.fn.extend({ resolve(); return defer.promise( obj ); } -}); -var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source; +} ); +var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source; + +var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); + var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; var isHidden = function( elem, el ) { + // isHidden might be called from jQuery#filter function; // in that case, element will be second argument elem = el || elem; - return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); + return jQuery.css( elem, "display" ) === "none" || + !jQuery.contains( elem.ownerDocument, elem ); }; -var rcheckableType = (/^(?:checkbox|radio)$/i); + + +function adjustCSS( elem, prop, valueParts, tween ) { + var adjusted, + scale = 1, + maxIterations = 20, + currentValue = tween ? + function() { return tween.cur(); } : + function() { return jQuery.css( elem, prop, "" ); }, + initial = currentValue(), + unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), + + // Starting value computation is required for potential unit mismatches + initialInUnit = ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + rcssNum.exec( jQuery.css( elem, prop ) ); + + if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { + + // Trust units reported by jQuery.css + unit = unit || initialInUnit[ 3 ]; + + // Make sure we update the tween properties later on + valueParts = valueParts || []; + + // Iteratively approximate from a nonzero starting point + initialInUnit = +initial || 1; + + do { + + // If previous iteration zeroed out, double until we get *something*. + // Use string for doubling so we don't accidentally see scale as unchanged below + scale = scale || ".5"; + + // Adjust and apply + initialInUnit = initialInUnit / scale; + jQuery.style( elem, prop, initialInUnit + unit ); + + // Update scale, tolerating zero or NaN from tween.cur() + // Break the loop if scale is unchanged or perfect, or if we've just had enough. + } while ( + scale !== ( scale = currentValue() / initial ) && scale !== 1 && --maxIterations + ); + } + + if ( valueParts ) { + initialInUnit = +initialInUnit || +initial || 0; + + // Apply relative offset (+=/-=) if specified + adjusted = valueParts[ 1 ] ? + initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] : + +valueParts[ 2 ]; + if ( tween ) { + tween.unit = unit; + tween.start = initialInUnit; + tween.end = adjusted; + } + } + return adjusted; +} +var rcheckableType = ( /^(?:checkbox|radio)$/i ); + +var rtagName = ( /<([\w:-]+)/ ); + +var rscriptType = ( /^$|\/(?:java|ecma)script/i ); -(function() { +// We have to close these tags to support XHTML (#13200) +var wrapMap = { + + // Support: IE9 + option: [ 1, "" ], + + // XHTML parsers do not magically insert elements in the + // same way that tag soup parsers do. So we cannot shorten + // this by omitting or other required elements. + thead: [ 1, "", "
" ], + col: [ 2, "", "
" ], + tr: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + _default: [ 0, "", "" ] +}; + +// Support: IE9 +wrapMap.optgroup = wrapMap.option; + +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + + +function getAll( context, tag ) { + + // Support: IE9-11+ + // Use typeof to avoid zero-argument method invocation on host objects (#15151) + var ret = typeof context.getElementsByTagName !== "undefined" ? + context.getElementsByTagName( tag || "*" ) : + typeof context.querySelectorAll !== "undefined" ? + context.querySelectorAll( tag || "*" ) : + []; + + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], ret ) : + ret; +} + + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + dataPriv.set( + elems[ i ], + "globalEval", + !refElements || dataPriv.get( refElements[ i ], "globalEval" ) + ); + } +} + + +var rhtml = /<|&#?\w+;/; + +function buildFragment( elems, context, scripts, selection, ignored ) { + var elem, tmp, tag, wrap, contains, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( jQuery.type( elem ) === "object" ) { + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement( "div" ) ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( ( elem = nodes[ i++ ] ) ) { + + // Skip elements already in the context collection (trac-4087) + if ( selection && jQuery.inArray( elem, selection ) > -1 ) { + if ( ignored ) { + ignored.push( elem ); + } + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( ( elem = tmp[ j++ ] ) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; +} + + +( function() { var fragment = document.createDocumentFragment(), div = fragment.appendChild( document.createElement( "div" ) ), input = document.createElement( "input" ); - // Support: Safari<=5.1 + // Support: Android 4.0-4.3, Safari<=5.1 // Check state lost if the name is set (#11217) // Support: Windows Web Apps (WWA) // `name` and `type` must use .setAttribute for WWA (#14901) @@ -4044,19 +4414,13 @@ var rcheckableType = (/^(?:checkbox|radio)$/i); // Make sure textarea (and checkbox) defaultValue is properly cloned div.innerHTML = ""; support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; -})(); -var strundefined = typeof undefined; - - - -support.focusinBubbles = "onfocusin" in window; +} )(); var rkeyEvent = /^key/, - rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/, - rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, - rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; function returnTrue() { return true; @@ -4066,12 +4430,75 @@ function returnFalse() { return false; } +// Support: IE9 +// See #13393 for more info function safeActiveElement() { try { return document.activeElement; } catch ( err ) { } } +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + /* * Helper functions for managing events -- not part of the public interface. * Props to Dean Edwards' addEvent library for many of the ideas. @@ -4085,7 +4512,7 @@ jQuery.event = { var handleObjIn, eventHandle, tmp, events, t, handleObj, special, handlers, type, namespaces, origType, - elemData = data_priv.get( elem ); + elemData = dataPriv.get( elem ); // Don't attach events to noData or text/comment nodes (but allow plain objects) if ( !elemData ) { @@ -4105,14 +4532,15 @@ jQuery.event = { } // Init the element's event structure and main handler, if this is the first - if ( !(events = elemData.events) ) { + if ( !( events = elemData.events ) ) { events = elemData.events = {}; } - if ( !(eventHandle = elemData.handle) ) { + if ( !( eventHandle = elemData.handle ) ) { eventHandle = elemData.handle = function( e ) { + // Discard the second event of a jQuery.event.trigger() and // when an event is called after a page has unloaded - return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ? + return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ? jQuery.event.dispatch.apply( elem, arguments ) : undefined; }; } @@ -4121,9 +4549,9 @@ jQuery.event = { types = ( types || "" ).match( rnotwhite ) || [ "" ]; t = types.length; while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); // There *must* be a type, no attaching namespace-only handlers if ( !type ) { @@ -4140,7 +4568,7 @@ jQuery.event = { special = jQuery.event.special[ type ] || {}; // handleObj is passed to all event handlers - handleObj = jQuery.extend({ + handleObj = jQuery.extend( { type: type, origType: origType, data: data, @@ -4148,18 +4576,20 @@ jQuery.event = { guid: handler.guid, selector: selector, needsContext: selector && jQuery.expr.match.needsContext.test( selector ), - namespace: namespaces.join(".") + namespace: namespaces.join( "." ) }, handleObjIn ); // Init the event handler queue if we're the first - if ( !(handlers = events[ type ]) ) { + if ( !( handlers = events[ type ] ) ) { handlers = events[ type ] = []; handlers.delegateCount = 0; // Only use addEventListener if the special events handler returns false - if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + if ( elem.addEventListener ) { - elem.addEventListener( type, eventHandle, false ); + elem.addEventListener( type, eventHandle ); } } } @@ -4191,9 +4621,9 @@ jQuery.event = { var j, origCount, tmp, events, t, handleObj, special, handlers, type, namespaces, origType, - elemData = data_priv.hasData( elem ) && data_priv.get( elem ); + elemData = dataPriv.hasData( elem ) && dataPriv.get( elem ); - if ( !elemData || !(events = elemData.events) ) { + if ( !elemData || !( events = elemData.events ) ) { return; } @@ -4201,9 +4631,9 @@ jQuery.event = { types = ( types || "" ).match( rnotwhite ) || [ "" ]; t = types.length; while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); // Unbind all events (on this namespace, if provided) for the element if ( !type ) { @@ -4216,7 +4646,8 @@ jQuery.event = { special = jQuery.event.special[ type ] || {}; type = ( selector ? special.delegateType : special.bindType ) || type; handlers = events[ type ] || []; - tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); // Remove matching events origCount = j = handlers.length; @@ -4226,7 +4657,8 @@ jQuery.event = { if ( ( mappedTypes || origType === handleObj.origType ) && ( !handler || handler.guid === handleObj.guid ) && ( !tmp || tmp.test( handleObj.namespace ) ) && - ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { handlers.splice( j, 1 ); if ( handleObj.selector ) { @@ -4241,7 +4673,9 @@ jQuery.event = { // Remove generic event handler if we removed something and no more handlers exist // (avoids potential for endless recursion during removal of special event handlers) if ( origCount && !handlers.length ) { - if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + jQuery.removeEvent( elem, type, elemData.handle ); } @@ -4249,145 +4683,12 @@ jQuery.event = { } } - // Remove the expando if it's no longer used + // Remove data and the expando if it's no longer used if ( jQuery.isEmptyObject( events ) ) { - delete elemData.handle; - data_priv.remove( elem, "events" ); + dataPriv.remove( elem, "handle events" ); } }, - trigger: function( event, data, elem, onlyHandlers ) { - - var i, cur, tmp, bubbleType, ontype, handle, special, - eventPath = [ elem || document ], - type = hasOwn.call( event, "type" ) ? event.type : event, - namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; - - cur = tmp = elem = elem || document; - - // Don't do events on text and comment nodes - if ( elem.nodeType === 3 || elem.nodeType === 8 ) { - return; - } - - // focus/blur morphs to focusin/out; ensure we're not firing them right now - if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { - return; - } - - if ( type.indexOf(".") >= 0 ) { - // Namespaced trigger; create a regexp to match event type in handle() - namespaces = type.split("."); - type = namespaces.shift(); - namespaces.sort(); - } - ontype = type.indexOf(":") < 0 && "on" + type; - - // Caller can pass in a jQuery.Event object, Object, or just an event type string - event = event[ jQuery.expando ] ? - event : - new jQuery.Event( type, typeof event === "object" && event ); - - // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) - event.isTrigger = onlyHandlers ? 2 : 3; - event.namespace = namespaces.join("."); - event.namespace_re = event.namespace ? - new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : - null; - - // Clean up the event in case it is being reused - event.result = undefined; - if ( !event.target ) { - event.target = elem; - } - - // Clone any incoming data and prepend the event, creating the handler arg list - data = data == null ? - [ event ] : - jQuery.makeArray( data, [ event ] ); - - // Allow special events to draw outside the lines - special = jQuery.event.special[ type ] || {}; - if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { - return; - } - - // Determine event propagation path in advance, per W3C events spec (#9951) - // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) - if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { - - bubbleType = special.delegateType || type; - if ( !rfocusMorph.test( bubbleType + type ) ) { - cur = cur.parentNode; - } - for ( ; cur; cur = cur.parentNode ) { - eventPath.push( cur ); - tmp = cur; - } - - // Only add window if we got to document (e.g., not plain obj or detached DOM) - if ( tmp === (elem.ownerDocument || document) ) { - eventPath.push( tmp.defaultView || tmp.parentWindow || window ); - } - } - - // Fire handlers on the event path - i = 0; - while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { - - event.type = i > 1 ? - bubbleType : - special.bindType || type; - - // jQuery handler - handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" ); - if ( handle ) { - handle.apply( cur, data ); - } - - // Native handler - handle = ontype && cur[ ontype ]; - if ( handle && handle.apply && jQuery.acceptData( cur ) ) { - event.result = handle.apply( cur, data ); - if ( event.result === false ) { - event.preventDefault(); - } - } - } - event.type = type; - - // If nobody prevented the default action, do it now - if ( !onlyHandlers && !event.isDefaultPrevented() ) { - - if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && - jQuery.acceptData( elem ) ) { - - // Call a native DOM method on the target with the same name name as the event. - // Don't do default actions on window, that's where global variables be (#6170) - if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { - - // Don't re-trigger an onFOO event when we call its FOO() method - tmp = elem[ ontype ]; - - if ( tmp ) { - elem[ ontype ] = null; - } - - // Prevent re-triggering of the same event, since we already bubbled it above - jQuery.event.triggered = type; - elem[ type ](); - jQuery.event.triggered = undefined; - - if ( tmp ) { - elem[ ontype ] = tmp; - } - } - } - } - - return event.result; - }, - dispatch: function( event ) { // Make a writable jQuery.Event from the native event object @@ -4396,11 +4697,11 @@ jQuery.event = { var i, j, ret, matched, handleObj, handlerQueue = [], args = slice.call( arguments ), - handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [], + handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [], special = jQuery.event.special[ event.type ] || {}; // Use the fix-ed jQuery.Event rather than the (read-only) native event - args[0] = event; + args[ 0 ] = event; event.delegateTarget = this; // Call the preDispatch hook for the mapped type, and let it bail if desired @@ -4413,24 +4714,25 @@ jQuery.event = { // Run delegates first; they may want to stop propagation beneath us i = 0; - while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { event.currentTarget = matched.elem; j = 0; - while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { // Triggered event must either 1) have no namespace, or 2) have namespace(s) // a subset or equal to those in the bound event (both can have no namespace). - if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { + if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) { event.handleObj = handleObj; event.data = handleObj.data; - ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) - .apply( matched.elem, args ); + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); if ( ret !== undefined ) { - if ( (event.result = ret) === false ) { + if ( ( event.result = ret ) === false ) { event.preventDefault(); event.stopPropagation(); } @@ -4453,15 +4755,20 @@ jQuery.event = { delegateCount = handlers.delegateCount, cur = event.target; + // Support (at least): Chrome, IE9 // Find delegate handlers // Black-hole SVG instance trees (#13180) - // Avoid non-left-click bubbling in Firefox (#3861) - if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { + // + // Support: Firefox<=42+ + // Avoid non-left-click in FF but don't block IE radio events (#3861, gh-2343) + if ( delegateCount && cur.nodeType && + ( event.type !== "click" || isNaN( event.button ) || event.button < 1 ) ) { for ( ; cur !== this; cur = cur.parentNode || this ) { + // Don't check non-elements (#13208) // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) - if ( cur.disabled !== true || event.type !== "click" ) { + if ( cur.nodeType === 1 && ( cur.disabled !== true || event.type !== "click" ) ) { matches = []; for ( i = 0; i < delegateCount; i++ ) { handleObj = handlers[ i ]; @@ -4471,7 +4778,7 @@ jQuery.event = { if ( matches[ sel ] === undefined ) { matches[ sel ] = handleObj.needsContext ? - jQuery( sel, this ).index( cur ) >= 0 : + jQuery( sel, this ).index( cur ) > -1 : jQuery.find( sel, this, null, [ cur ] ).length; } if ( matches[ sel ] ) { @@ -4479,7 +4786,7 @@ jQuery.event = { } } if ( matches.length ) { - handlerQueue.push({ elem: cur, handlers: matches }); + handlerQueue.push( { elem: cur, handlers: matches } ); } } } @@ -4487,19 +4794,20 @@ jQuery.event = { // Add the remaining (directly-bound) handlers if ( delegateCount < handlers.length ) { - handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); + handlerQueue.push( { elem: this, handlers: handlers.slice( delegateCount ) } ); } return handlerQueue; }, // Includes some event props shared by KeyEvent and MouseEvent - props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + props: ( "altKey bubbles cancelable ctrlKey currentTarget detail eventPhase " + + "metaKey relatedTarget shiftKey target timeStamp view which" ).split( " " ), fixHooks: {}, keyHooks: { - props: "char charCode key keyCode".split(" "), + props: "char charCode key keyCode".split( " " ), filter: function( event, original ) { // Add which for key events @@ -4512,7 +4820,8 @@ jQuery.event = { }, mouseHooks: { - props: "button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + props: ( "button buttons clientX clientY offsetX offsetY pageX pageY " + + "screenX screenY toElement" ).split( " " ), filter: function( event, original ) { var eventDoc, doc, body, button = original.button; @@ -4523,8 +4832,12 @@ jQuery.event = { doc = eventDoc.documentElement; body = eventDoc.body; - event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); - event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); + event.pageX = original.clientX + + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - + ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - + ( doc && doc.clientTop || body && body.clientTop || 0 ); } // Add which for click: 1 === left; 2 === middle; 3 === right @@ -4581,10 +4894,12 @@ jQuery.event = { special: { load: { + // Prevent triggered image.load events from bubbling to window.load noBubble: true }, focus: { + // Fire native event if possible so blur/focus sequence is correct trigger: function() { if ( this !== safeActiveElement() && this.focus ) { @@ -4604,6 +4919,7 @@ jQuery.event = { delegateType: "focusout" }, click: { + // For checkbox, fire native event so checked state will be right trigger: function() { if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { @@ -4628,41 +4944,21 @@ jQuery.event = { } } } - }, - - simulate: function( type, elem, event, bubble ) { - // Piggyback on a donor event to simulate a different one. - // Fake originalEvent to avoid donor's stopPropagation, but if the - // simulated event prevents default then we do the same on the donor. - var e = jQuery.extend( - new jQuery.Event(), - event, - { - type: type, - isSimulated: true, - originalEvent: {} - } - ); - if ( bubble ) { - jQuery.event.trigger( e, null, elem ); - } else { - jQuery.event.dispatch.call( elem, e ); - } - if ( e.isDefaultPrevented() ) { - event.preventDefault(); - } } }; jQuery.removeEvent = function( elem, type, handle ) { + + // This "if" is needed for plain objects if ( elem.removeEventListener ) { - elem.removeEventListener( type, handle, false ); + elem.removeEventListener( type, handle ); } }; jQuery.Event = function( src, props ) { + // Allow instantiation without the 'new' keyword - if ( !(this instanceof jQuery.Event) ) { + if ( !( this instanceof jQuery.Event ) ) { return new jQuery.Event( src, props ); } @@ -4675,6 +4971,7 @@ jQuery.Event = function( src, props ) { // by a handler lower down the tree; reflect the correct value. this.isDefaultPrevented = src.defaultPrevented || src.defaultPrevented === undefined && + // Support: Android<4.0 src.returnValue === false ? returnTrue : @@ -4700,6 +4997,7 @@ jQuery.Event = function( src, props ) { // jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding // http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html jQuery.Event.prototype = { + constructor: jQuery.Event, isDefaultPrevented: returnFalse, isPropagationStopped: returnFalse, isImmediatePropagationStopped: returnFalse, @@ -4709,7 +5007,7 @@ jQuery.Event.prototype = { this.isDefaultPrevented = returnTrue; - if ( e && e.preventDefault ) { + if ( e ) { e.preventDefault(); } }, @@ -4718,7 +5016,7 @@ jQuery.Event.prototype = { this.isPropagationStopped = returnTrue; - if ( e && e.stopPropagation ) { + if ( e ) { e.stopPropagation(); } }, @@ -4727,7 +5025,7 @@ jQuery.Event.prototype = { this.isImmediatePropagationStopped = returnTrue; - if ( e && e.stopImmediatePropagation ) { + if ( e ) { e.stopImmediatePropagation(); } @@ -4736,8 +5034,14 @@ jQuery.Event.prototype = { }; // Create mouseenter/leave events using mouseover/out and event-time checks -// Support: Chrome 15+ -jQuery.each({ +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://code.google.com/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { mouseenter: "mouseover", mouseleave: "mouseout", pointerenter: "pointerover", @@ -4753,9 +5057,9 @@ jQuery.each({ related = event.relatedTarget, handleObj = event.handleObj; - // For mousenter/leave call the handler if related is outside the target. + // For mouseenter/leave call the handler if related is outside the target. // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !jQuery.contains( target, related )) ) { + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { event.type = handleObj.origType; ret = handleObj.handler.apply( this, arguments ); event.type = fix; @@ -4763,115 +5067,32 @@ jQuery.each({ return ret; } }; -}); +} ); -// Support: Firefox, Chrome, Safari -// Create "bubbling" focus and blur events -if ( !support.focusinBubbles ) { - jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { - - // Attach a single capturing handler on the document while someone wants focusin/focusout - var handler = function( event ) { - jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); - }; - - jQuery.event.special[ fix ] = { - setup: function() { - var doc = this.ownerDocument || this, - attaches = data_priv.access( doc, fix ); - - if ( !attaches ) { - doc.addEventListener( orig, handler, true ); - } - data_priv.access( doc, fix, ( attaches || 0 ) + 1 ); - }, - teardown: function() { - var doc = this.ownerDocument || this, - attaches = data_priv.access( doc, fix ) - 1; - - if ( !attaches ) { - doc.removeEventListener( orig, handler, true ); - data_priv.remove( doc, fix ); - - } else { - data_priv.access( doc, fix, attaches ); - } - } - }; - }); -} - -jQuery.fn.extend({ - - on: function( types, selector, data, fn, /*INTERNAL*/ one ) { - var origFn, type; - - // Types can be a map of types/handlers - if ( typeof types === "object" ) { - // ( types-Object, selector, data ) - if ( typeof selector !== "string" ) { - // ( types-Object, data ) - data = data || selector; - selector = undefined; - } - for ( type in types ) { - this.on( type, selector, data, types[ type ], one ); - } - return this; - } - - if ( data == null && fn == null ) { - // ( types, fn ) - fn = selector; - data = selector = undefined; - } else if ( fn == null ) { - if ( typeof selector === "string" ) { - // ( types, selector, fn ) - fn = data; - data = undefined; - } else { - // ( types, data, fn ) - fn = data; - data = selector; - selector = undefined; - } - } - if ( fn === false ) { - fn = returnFalse; - } else if ( !fn ) { - return this; - } - - if ( one === 1 ) { - origFn = fn; - fn = function( event ) { - // Can use an empty set, since event contains the info - jQuery().off( event ); - return origFn.apply( this, arguments ); - }; - // Use same guid so caller can remove using origFn - fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); - } - return this.each( function() { - jQuery.event.add( this, types, fn, data, selector ); - }); +jQuery.fn.extend( { + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); }, one: function( types, selector, data, fn ) { - return this.on( types, selector, data, fn, 1 ); + return on( this, types, selector, data, fn, 1 ); }, off: function( types, selector, fn ) { var handleObj, type; if ( types && types.preventDefault && types.handleObj ) { + // ( event ) dispatched jQuery.Event handleObj = types.handleObj; jQuery( types.delegateTarget ).off( - handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, handleObj.selector, handleObj.handler ); return this; } if ( typeof types === "object" ) { + // ( types-object [, selector] ) for ( type in types ) { this.off( type, selector, types[ type ] ); @@ -4879,6 +5100,7 @@ jQuery.fn.extend({ return this; } if ( selector === false || typeof selector === "function" ) { + // ( types [, fn] ) fn = selector; selector = undefined; @@ -4886,70 +5108,39 @@ jQuery.fn.extend({ if ( fn === false ) { fn = returnFalse; } - return this.each(function() { + return this.each( function() { jQuery.event.remove( this, types, fn, selector ); - }); - }, - - trigger: function( type, data ) { - return this.each(function() { - jQuery.event.trigger( type, data, this ); - }); - }, - triggerHandler: function( type, data ) { - var elem = this[0]; - if ( elem ) { - return jQuery.event.trigger( type, data, elem, true ); - } + } ); } -}); +} ); var - rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, - rtagName = /<([\w:]+)/, - rhtml = /<|&#?\w+;/, - rnoInnerhtml = /<(?:script|style|link)/i, + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi, + + // Support: IE 10-11, Edge 10240+ + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /\s*$/g, + rcleanScript = /^\s*\s*$/g; - // We have to close these tags to support XHTML (#13200) - wrapMap = { - - // Support: IE9 - option: [ 1, "" ], - - thead: [ 1, "", "
" ], - col: [ 2, "", "
" ], - tr: [ 2, "", "
" ], - td: [ 3, "", "
" ], - - _default: [ 0, "", "" ] - }; - -// Support: IE9 -wrapMap.optgroup = wrapMap.option; - -wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; -wrapMap.th = wrapMap.td; - -// Support: 1.x compatibility -// Manipulating tables requires a tbody function manipulationTarget( elem, content ) { - return jQuery.nodeName( elem, "table" ) && - jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? + if ( jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ) { - elem.getElementsByTagName("tbody")[0] || - elem.appendChild( elem.ownerDocument.createElement("tbody") ) : - elem; + return elem.getElementsByTagName( "tbody" )[ 0 ] || elem; + } + + return elem; } // Replace/restore the type attribute of script elements for safe DOM manipulation function disableScript( elem ) { - elem.type = (elem.getAttribute("type") !== null) + "/" + elem.type; + elem.type = ( elem.getAttribute( "type" ) !== null ) + "/" + elem.type; return elem; } function restoreScript( elem ) { @@ -4958,24 +5149,12 @@ function restoreScript( elem ) { if ( match ) { elem.type = match[ 1 ]; } else { - elem.removeAttribute("type"); + elem.removeAttribute( "type" ); } return elem; } -// Mark scripts as having already been evaluated -function setGlobalEval( elems, refElements ) { - var i = 0, - l = elems.length; - - for ( ; i < l; i++ ) { - data_priv.set( - elems[ i ], "globalEval", !refElements || data_priv.get( refElements[ i ], "globalEval" ) - ); - } -} - function cloneCopyEvent( src, dest ) { var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; @@ -4984,9 +5163,9 @@ function cloneCopyEvent( src, dest ) { } // 1. Copy private data: events, handlers, etc. - if ( data_priv.hasData( src ) ) { - pdataOld = data_priv.access( src ); - pdataCur = data_priv.set( dest, pdataOld ); + if ( dataPriv.hasData( src ) ) { + pdataOld = dataPriv.access( src ); + pdataCur = dataPriv.set( dest, pdataOld ); events = pdataOld.events; if ( events ) { @@ -5002,24 +5181,14 @@ function cloneCopyEvent( src, dest ) { } // 2. Copy user data - if ( data_user.hasData( src ) ) { - udataOld = data_user.access( src ); + if ( dataUser.hasData( src ) ) { + udataOld = dataUser.access( src ); udataCur = jQuery.extend( {}, udataOld ); - data_user.set( dest, udataCur ); + dataUser.set( dest, udataCur ); } } -function getAll( context, tag ) { - var ret = context.getElementsByTagName ? context.getElementsByTagName( tag || "*" ) : - context.querySelectorAll ? context.querySelectorAll( tag || "*" ) : - []; - - return tag === undefined || tag && jQuery.nodeName( context, tag ) ? - jQuery.merge( [ context ], ret ) : - ret; -} - // Fix IE bugs, see support tests function fixInput( src, dest ) { var nodeName = dest.nodeName.toLowerCase(); @@ -5034,7 +5203,122 @@ function fixInput( src, dest ) { } } -jQuery.extend({ +function domManip( collection, args, callback, ignored ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !dataPriv.access( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); + } + } + } + } + } + } + + return collection; +} + +function remove( elem, selector, keepData ) { + var node, + nodes = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = nodes[ i ] ) != null; i++ ) { + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && jQuery.contains( node.ownerDocument, node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } + } + + return elem; +} + +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1>" ); + }, + clone: function( elem, dataAndEvents, deepDataAndEvents ) { var i, l, srcElements, destElements, clone = elem.cloneNode( true ), @@ -5077,102 +5361,14 @@ jQuery.extend({ return clone; }, - buildFragment: function( elems, context, scripts, selection ) { - var elem, tmp, tag, wrap, contains, j, - fragment = context.createDocumentFragment(), - nodes = [], - i = 0, - l = elems.length; - - for ( ; i < l; i++ ) { - elem = elems[ i ]; - - if ( elem || elem === 0 ) { - - // Add nodes directly - if ( jQuery.type( elem ) === "object" ) { - // Support: QtWebKit, PhantomJS - // push.apply(_, arraylike) throws on ancient WebKit - jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); - - // Convert non-html into a text node - } else if ( !rhtml.test( elem ) ) { - nodes.push( context.createTextNode( elem ) ); - - // Convert html into DOM nodes - } else { - tmp = tmp || fragment.appendChild( context.createElement("div") ); - - // Deserialize a standard representation - tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); - wrap = wrapMap[ tag ] || wrapMap._default; - tmp.innerHTML = wrap[ 1 ] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[ 2 ]; - - // Descend through wrappers to the right content - j = wrap[ 0 ]; - while ( j-- ) { - tmp = tmp.lastChild; - } - - // Support: QtWebKit, PhantomJS - // push.apply(_, arraylike) throws on ancient WebKit - jQuery.merge( nodes, tmp.childNodes ); - - // Remember the top-level container - tmp = fragment.firstChild; - - // Ensure the created nodes are orphaned (#12392) - tmp.textContent = ""; - } - } - } - - // Remove wrapper from fragment - fragment.textContent = ""; - - i = 0; - while ( (elem = nodes[ i++ ]) ) { - - // #4087 - If origin and destination elements are the same, and this is - // that element, do not do anything - if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { - continue; - } - - contains = jQuery.contains( elem.ownerDocument, elem ); - - // Append to fragment - tmp = getAll( fragment.appendChild( elem ), "script" ); - - // Preserve script evaluation history - if ( contains ) { - setGlobalEval( tmp ); - } - - // Capture executables - if ( scripts ) { - j = 0; - while ( (elem = tmp[ j++ ]) ) { - if ( rscriptType.test( elem.type || "" ) ) { - scripts.push( elem ); - } - } - } - } - - return fragment; - }, - cleanData: function( elems ) { - var data, elem, type, key, + var data, elem, type, special = jQuery.event.special, i = 0; - for ( ; (elem = elems[ i ]) !== undefined; i++ ) { - if ( jQuery.acceptData( elem ) ) { - key = elem[ data_priv.expando ]; - - if ( key && (data = data_priv.cache[ key ]) ) { + for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) { + if ( acceptData( elem ) ) { + if ( ( data = elem[ dataPriv.expando ] ) ) { if ( data.events ) { for ( type in data.events ) { if ( special[ type ] ) { @@ -5184,91 +5380,86 @@ jQuery.extend({ } } } - if ( data_priv.cache[ key ] ) { - // Discard any remaining `private` data - delete data_priv.cache[ key ]; - } + + // Support: Chrome <= 35-45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataPriv.expando ] = undefined; + } + if ( elem[ dataUser.expando ] ) { + + // Support: Chrome <= 35-45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataUser.expando ] = undefined; } } - // Discard any remaining `user` data - delete data_user.cache[ elem[ data_user.expando ] ]; } } -}); +} ); + +jQuery.fn.extend( { + + // Keep domManip exposed until 3.0 (gh-2225) + domManip: domManip, + + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, -jQuery.fn.extend({ text: function( value ) { return access( this, function( value ) { return value === undefined ? jQuery.text( this ) : - this.empty().each(function() { + this.empty().each( function() { if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { this.textContent = value; } - }); + } ); }, null, value, arguments.length ); }, append: function() { - return this.domManip( arguments, function( elem ) { + return domManip( this, arguments, function( elem ) { if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { var target = manipulationTarget( this, elem ); target.appendChild( elem ); } - }); + } ); }, prepend: function() { - return this.domManip( arguments, function( elem ) { + return domManip( this, arguments, function( elem ) { if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { var target = manipulationTarget( this, elem ); target.insertBefore( elem, target.firstChild ); } - }); + } ); }, before: function() { - return this.domManip( arguments, function( elem ) { + return domManip( this, arguments, function( elem ) { if ( this.parentNode ) { this.parentNode.insertBefore( elem, this ); } - }); + } ); }, after: function() { - return this.domManip( arguments, function( elem ) { + return domManip( this, arguments, function( elem ) { if ( this.parentNode ) { this.parentNode.insertBefore( elem, this.nextSibling ); } - }); - }, - - remove: function( selector, keepData /* Internal Use Only */ ) { - var elem, - elems = selector ? jQuery.filter( selector, this ) : this, - i = 0; - - for ( ; (elem = elems[i]) != null; i++ ) { - if ( !keepData && elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem ) ); - } - - if ( elem.parentNode ) { - if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { - setGlobalEval( getAll( elem, "script" ) ); - } - elem.parentNode.removeChild( elem ); - } - } - - return this; + } ); }, empty: function() { var elem, i = 0; - for ( ; (elem = this[i]) != null; i++ ) { + for ( ; ( elem = this[ i ] ) != null; i++ ) { if ( elem.nodeType === 1 ) { // Prevent memory leaks @@ -5286,9 +5477,9 @@ jQuery.fn.extend({ dataAndEvents = dataAndEvents == null ? false : dataAndEvents; deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; - return this.map(function() { + return this.map( function() { return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); - }); + } ); }, html: function( value ) { @@ -5305,7 +5496,7 @@ jQuery.fn.extend({ if ( typeof value === "string" && !rnoInnerhtml.test( value ) && !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { - value = value.replace( rxhtmlTag, "<$1>" ); + value = jQuery.htmlPrefilter( value ); try { for ( ; i < l; i++ ) { @@ -5321,7 +5512,7 @@ jQuery.fn.extend({ elem = 0; // If using innerHTML throws an exception, use the fallback method - } catch( e ) {} + } catch ( e ) {} } if ( elem ) { @@ -5331,115 +5522,25 @@ jQuery.fn.extend({ }, replaceWith: function() { - var arg = arguments[ 0 ]; + var ignored = []; - // Make the changes, replacing each context element with the new content - this.domManip( arguments, function( elem ) { - arg = this.parentNode; + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; - jQuery.cleanData( getAll( this ) ); - - if ( arg ) { - arg.replaceChild( elem, this ); - } - }); - - // Force removal if there was no new content (e.g., from empty arguments) - return arg && (arg.length || arg.nodeType) ? this : this.remove(); - }, - - detach: function( selector ) { - return this.remove( selector, true ); - }, - - domManip: function( args, callback ) { - - // Flatten any nested arrays - args = concat.apply( [], args ); - - var fragment, first, scripts, hasScripts, node, doc, - i = 0, - l = this.length, - set = this, - iNoClone = l - 1, - value = args[ 0 ], - isFunction = jQuery.isFunction( value ); - - // We can't cloneNode fragments that contain checked, in WebKit - if ( isFunction || - ( l > 1 && typeof value === "string" && - !support.checkClone && rchecked.test( value ) ) ) { - return this.each(function( index ) { - var self = set.eq( index ); - if ( isFunction ) { - args[ 0 ] = value.call( this, index, self.html() ); - } - self.domManip( args, callback ); - }); - } - - if ( l ) { - fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); - first = fragment.firstChild; - - if ( fragment.childNodes.length === 1 ) { - fragment = first; - } - - if ( first ) { - scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); - hasScripts = scripts.length; - - // Use the original fragment for the last item instead of the first because it can end up - // being emptied incorrectly in certain situations (#8070). - for ( ; i < l; i++ ) { - node = fragment; - - if ( i !== iNoClone ) { - node = jQuery.clone( node, true, true ); - - // Keep references to cloned scripts for later restoration - if ( hasScripts ) { - // Support: QtWebKit - // jQuery.merge because push.apply(_, arraylike) throws - jQuery.merge( scripts, getAll( node, "script" ) ); - } - } - - callback.call( this[ i ], node, i ); - } - - if ( hasScripts ) { - doc = scripts[ scripts.length - 1 ].ownerDocument; - - // Reenable scripts - jQuery.map( scripts, restoreScript ); - - // Evaluate executable scripts on first document insertion - for ( i = 0; i < hasScripts; i++ ) { - node = scripts[ i ]; - if ( rscriptType.test( node.type || "" ) && - !data_priv.access( node, "globalEval" ) && jQuery.contains( doc, node ) ) { - - if ( node.src ) { - // Optional AJAX dependency, but won't run scripts if not present - if ( jQuery._evalUrl ) { - jQuery._evalUrl( node.src ); - } - } else { - jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); - } - } - } + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); } } - } - return this; + // Force callback invocation + }, ignored ); } -}); +} ); -jQuery.each({ +jQuery.each( { appendTo: "append", prependTo: "prepend", insertBefore: "before", @@ -5464,28 +5565,29 @@ jQuery.each({ return this.pushStack( ret ); }; -}); +} ); var iframe, - elemdisplay = {}; + elemdisplay = { + + // Support: Firefox + // We have to pre-define these values for FF (#10227) + HTML: "block", + BODY: "block" + }; /** * Retrieve the actual display of a element * @param {String} name nodeName of the element * @param {Object} doc Document object */ + // Called only from within defaultDisplay function actualDisplay( name, doc ) { - var style, - elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), + var elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), - // getDefaultComputedStyle might be reliably used only on attached element - display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ? - - // Use of this method is a temporary fix (more like optimization) until something better comes along, - // since it was removed from specification and supported only in FF - style.display : jQuery.css( elem[ 0 ], "display" ); + display = jQuery.css( elem[ 0 ], "display" ); // We don't have any data stored on the element, // so use "detach" method as fast way to get rid of the element @@ -5509,7 +5611,8 @@ function defaultDisplay( nodeName ) { if ( display === "none" || !display ) { // Use the already-created iframe if possible - iframe = (iframe || jQuery( "