diff --git a/.codeclimate.yml b/.codeclimate.yml index 975bbe566c..009a4859a0 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -6,7 +6,6 @@ languages: exclude_paths: - "app/assets/javascripts/defer/*" - - "app/assets/javascripts/discourse/lib/Markdown.Editor.js" - "app/assets/javascripts/ember-addons/*" - "lib/autospec/*" - "lib/es6_module_transpiler/*" diff --git a/.eslintignore b/.eslintignore index 5b61bf6c51..87cbecfae7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,9 +6,7 @@ app/assets/javascripts/pagedown_custom.js app/assets/javascripts/vendor.js app/assets/javascripts/locales/i18n.js app/assets/javascripts/defer/html-sanitizer-bundle.js -app/assets/javascripts/discourse/lib/Markdown.Editor.js app/assets/javascripts/ember-addons/ -jsapp/lib/Markdown.Editor.js lib/javascripts/locale/ lib/javascripts/messageformat.js lib/javascripts/moment.js diff --git a/Gemfile b/Gemfile index 92c81e10bd..f7eca24642 100644 --- a/Gemfile +++ b/Gemfile @@ -120,6 +120,7 @@ group :test, :development do gem 'simplecov', require: false gem 'timecop' gem 'rspec-given' + gem 'rspec-html-matchers' gem 'pry-nav' gem 'spork-rails' gem 'byebug', require: ENV['RM_INFO'].nil? diff --git a/Gemfile.lock b/Gemfile.lock index 8142f566d5..94f520b288 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -299,7 +299,7 @@ GEM loofah (~> 2.0) rails-observers (0.1.2) activemodel (~> 4.0) - rails_multisite (1.0.2) + rails_multisite (1.0.3) railties (4.2.4) actionpack (= 4.2.4) activesupport (= 4.2.4) @@ -337,6 +337,9 @@ GEM rspec-given (3.5.4) given_core (= 3.5.4) rspec (>= 2.12) + rspec-html-matchers (0.7.0) + nokogiri (~> 1) + rspec (~> 3) rspec-logsplit (0.1.3) rspec-mocks (3.2.1) diff-lcs (>= 1.2.0, < 2.0) @@ -511,6 +514,7 @@ DEPENDENCIES rmmseg-cpp rspec (~> 3.2.0) rspec-given + rspec-html-matchers rspec-rails rtlit ruby-readability diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index dbe31af85e..542e15c987 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -67,6 +67,22 @@ export default Ember.Controller.extend({ }); }, + removeOwner(member) { + const self = this, + message = I18n.t("admin.groups.delete_owner_confirm", { username: member.get("username"), group: this.get("model.name") }); + return bootbox.confirm(message, I18n.t("no_value"), I18n.t("yes_value"), function(confirm) { + if (confirm) { + self.get("model").removeOwner(member); + } + }); + }, + + addOwners() { + if (Em.isEmpty(this.get("model.ownerUsernames"))) { return; } + this.get("model").addOwners(this.get("model.ownerUsernames")).catch(popupAjaxError); + this.set("model.ownerUsernames", null); + }, + addMembers() { if (Em.isEmpty(this.get("model.usernames"))) { return; } this.get("model").addMembers(this.get("model.usernames")).catch(popupAjaxError); diff --git a/app/assets/javascripts/admin/templates/embedding.hbs b/app/assets/javascripts/admin/templates/embedding.hbs index 30f279105d..6fbe31c124 100644 --- a/app/assets/javascripts/admin/templates/embedding.hbs +++ b/app/assets/javascripts/admin/templates/embedding.hbs @@ -53,6 +53,10 @@ {{embedding-setting field="embed_blacklist_selector" value=embedding.embed_blacklist_selector placeholder=".ad-unit, header"}} + + {{embedding-setting field="embed_classname_whitelist" + value=embedding.embed_classname_whitelist + placeholder="emoji, classname"}}
diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index ac1f979691..81e2da28de 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -10,6 +10,23 @@
{{#if model.id}} + {{#unless model.automatic}} + {{#if model.hasOwners}} +
+ +
+ {{#each model.owners as |member|}} + {{group-member member=member removeAction="removeOwner"}} + {{/each}} +
+
+ {{/if}} +
+ + {{user-selector usernames=model.ownerUsernames placeholderKey="admin.groups.selector_placeholder" id="owner-selector"}} + {{d-button action="addOwners" class="add" icon="plus" label="admin.groups.add"}} +
+ {{/unless}}
diff --git a/app/assets/javascripts/admin/templates/modal/admin_agree_flag.hbs b/app/assets/javascripts/admin/templates/modal/admin_agree_flag.hbs index ef77954b99..6c494e0e2c 100644 --- a/app/assets/javascripts/admin/templates/modal/admin_agree_flag.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin_agree_flag.hbs @@ -7,5 +7,5 @@ {{/if}} {{#if model.canDeleteAsSpammer}} - + {{/if}} diff --git a/app/assets/javascripts/admin/templates/modal/admin_delete_flag.hbs b/app/assets/javascripts/admin/templates/modal/admin_delete_flag.hbs index de5d62b2db..926f81bd21 100644 --- a/app/assets/javascripts/admin/templates/modal/admin_delete_flag.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin_delete_flag.hbs @@ -1,5 +1,5 @@ {{#if model.canDeleteAsSpammer}} - + {{/if}} diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index 96f4c90c6d..5b16593a82 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -16,13 +16,10 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, { // if it's a non relative URL, return it. if (url !== '/' && !/^\/[^\/]/.test(url)) return url; - var u = Discourse.BaseUri === undefined ? "/" : Discourse.BaseUri; + if (url.indexOf(Discourse.BaseUri) !== -1) return url; + if (url[0] !== "/") url = "/" + url; - if (u[u.length-1] === '/') u = u.substring(0, u.length-1); - if (url.indexOf(u) !== -1) return url; - if (u.length > 0 && url[0] !== "/") url = "/" + url; - - return u + url; + return Discourse.BaseUri + url; }, getURLWithCDN: function(url) { diff --git a/app/assets/javascripts/discourse/components/combo-box.js.es6 b/app/assets/javascripts/discourse/components/combo-box.js.es6 index fdda11db1a..340ba96615 100644 --- a/app/assets/javascripts/discourse/components/combo-box.js.es6 +++ b/app/assets/javascripts/discourse/components/combo-box.js.es6 @@ -72,7 +72,8 @@ export default Ember.Component.extend({ } const $elem = this.$(); - $elem.select2({formatResult: this.comboTemplate, minimumResultsForSearch: 5, width: 'resolve'}); + const minimumResultsForSearch = this.capabilities.touch ? -1 : 5; + $elem.select2({formatResult: this.comboTemplate, minimumResultsForSearch, width: 'resolve'}); const castInteger = this.get('castInteger'); const self = this; diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 new file mode 100644 index 0000000000..03a8369244 --- /dev/null +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -0,0 +1,379 @@ +import userSearch from 'discourse/lib/user-search'; +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; +import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; + +export default Ember.Component.extend({ + classNames: ['wmd-controls'], + classNameBindings: [':wmd-controls', 'showPreview', 'showPreview::hide-preview'], + + uploadProgress: 0, + showPreview: true, + _xhr: null, + + @computed + uploadPlaceholder() { + return `[${I18n.t('uploading')}]() `; + }, + + @on('init') + _setupPreview() { + const val = (Discourse.Mobile.mobileView ? false : (this.keyValueStore.get('composer.showPreview') || 'true')); + this.set('showPreview', val === 'true'); + }, + + @computed('showPreview') + toggleText: function(showPreview) { + return showPreview ? I18n.t('composer.hide_preview') : I18n.t('composer.show_preview'); + }, + + @computed + markdownOptions() { + return { + lookupAvatarByPostNumber: (postNumber, topicId) => { + const topic = this.get('topic'); + if (!topic) { return; } + + const posts = topic.get('postStream.posts'); + if (posts && topicId === topic.get('id')) { + const quotedPost = posts.findProperty("post_number", postNumber); + if (quotedPost) { + return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template')); + } + } + } + }; + }, + + @on('didInsertElement') + _composerEditorInit() { + const topicId = this.get('topic.id'); + const template = this.container.lookup('template:user-selector-autocomplete.raw'); + const $input = this.$('.d-editor-input'); + $input.autocomplete({ + template, + dataSource: term => userSearch({ term, topicId, includeGroups: true }), + key: "@", + transformComplete: v => v.username || v.usernames.join(", @") + }); + + $input.on('scroll', () => Ember.run.throttle(this, this._syncEditorAndPreviewScroll, 20)); + + // Focus on the body unless we have a title + if (!this.get('composer.canEditTitle') && !this.capabilities.touch) { + this.$('.d-editor-input').putCursorAtEnd(); + } + + this._bindUploadTarget(); + this.appEvents.trigger('composer:opened'); + }, + + @computed('composer.reply', 'composer.replyLength', 'composer.missingReplyCharacters', 'composer.minimumPostLength', 'lastValidatedAt') + validation(reply, replyLength, missingReplyCharacters, minimumPostLength, lastValidatedAt) { + const postType = this.get('composer.post.post_type'); + if (postType === this.site.get('post_types.small_action')) { return; } + + let reason; + if (replyLength < 1) { + reason = I18n.t('composer.error.post_missing'); + } else if (missingReplyCharacters > 0) { + reason = I18n.t('composer.error.post_length', {min: minimumPostLength}); + const tl = Discourse.User.currentProp("trust_level"); + if (tl === 0 || tl === 1) { + reason += "
" + I18n.t('composer.error.try_like'); + } + } + + if (reason) { + return Discourse.InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); + } + }, + + _syncEditorAndPreviewScroll() { + const $input = this.$('.d-editor-input'); + const $preview = this.$('.d-editor-preview'); + + if ($input.scrollTop() === 0) { + $preview.scrollTop(0); + return; + } + + const inputHeight = $input[0].scrollHeight; + const previewHeight = $preview[0].scrollHeight; + if (($input.height() + $input.scrollTop() + 100) > inputHeight) { + // cheat, special case for bottom + $preview.scrollTop(previewHeight); + return; + } + + const scrollPosition = $input.scrollTop(); + const factor = previewHeight / inputHeight; + const desired = scrollPosition * factor; + $preview.scrollTop(desired + 50); + }, + + _renderUnseen: function($preview, unseen) { + fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => { + linkSeenMentions($preview, this.siteSettings); + }); + }, + + _resetUpload() { + this.setProperties({ uploadProgress: 0, isUploading: false }); + this.set('composer.reply', this.get('composer.reply').replace(this.get('uploadPlaceholder'), "")); + }, + + _bindUploadTarget() { + this._unbindUploadTarget(); // in case it's still bound, let's clean it up first + + const $element = this.$();; + const csrf = this.session.get('csrfToken'); + const uploadPlaceholder = this.get('uploadPlaceholder'); + + $element.fileupload({ + url: Discourse.getURL(`/uploads.json?client_id=${this.messageBus.clientId}&authenticity_token=${encodeURIComponent(csrf)}`), + dataType: "json", + pasteZone: $element, + }); + + $element.on('fileuploadsubmit', (e, data) => { + const isUploading = Discourse.Utilities.validateUploadedFiles(data.files); + data.formData = { type: "composer" }; + this.setProperties({ uploadProgress: 0, isUploading }); + return isUploading; + }); + + $element.on("fileuploadprogressall", (e, data) => { + this.set("uploadProgress", parseInt(data.loaded / data.total * 100, 10)); + }); + + $element.on("fileuploadsend", (e, data) => { + // add upload placeholder + this.appEvents.trigger('composer:insert-text', uploadPlaceholder); + + if (data.xhr) { + this._xhr = data.xhr(); + } + }); + + $element.on("fileuploadfail", (e, data) => { + this._resetUpload(); + + const userCancelled = this._xhr && this._xhr._userCancelled; + this._xhr = null; + + if (!userCancelled) { + Discourse.Utilities.displayErrorForUpload(data); + } + }); + + this.messageBus.subscribe("/uploads/composer", upload => { + // replace upload placeholder + if (upload && upload.url) { + if (!this._xhr || !this._xhr._userCancelled) { + const markdown = Discourse.Utilities.getUploadMarkdown(upload); + this.set('composer.reply', this.get('composer.reply').replace(uploadPlaceholder, markdown)); + } + } else { + Discourse.Utilities.displayErrorForUpload(upload); + } + + // reset upload state + this._resetUpload(); + }); + + if (Discourse.Mobile.mobileView) { + this.$(".mobile-file-upload").on("click.uploader", function () { + // redirect the click on the hidden file input + $("#mobile-uploader").click(); + }); + } + + this._firefoxPastingHack(); + }, + + // Believe it or not pasting an image in Firefox doesn't work without this code + _firefoxPastingHack() { + const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); + if (uaMatch && parseInt(uaMatch[1]) >= 24) { + this.$().append( Ember.$("
") ); + this.$("textarea").off('keydown.contenteditable'); + this.$("textarea").on('keydown.contenteditable', event => { + // Catch Ctrl+v / Cmd+v and hijack focus to a contenteditable div. We can't + // use the onpaste event because for some reason the paste isn't resumed + // after we switch focus, probably because it is being executed too late. + if ((event.ctrlKey || event.metaKey) && (event.keyCode === 86)) { + // Save the current textarea selection. + const textarea = this.$("textarea")[0]; + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + + // Focus the contenteditable div. + const contentEditableDiv = this.$('#contenteditable'); + contentEditableDiv.focus(); + + // The paste doesn't finish immediately and we don't have any onpaste + // event, so wait for 100ms which _should_ be enough time. + setTimeout(() => { + const pastedImg = contentEditableDiv.find('img'); + + if ( pastedImg.length === 1 ) { + pastedImg.remove(); + } + + // For restoring the selection. + textarea.focus(); + const textareaContent = $(textarea).val(), + startContent = textareaContent.substring(0, selectionStart), + endContent = textareaContent.substring(selectionEnd); + + const restoreSelection = function(pastedText) { + $(textarea).val( startContent + pastedText + endContent ); + textarea.selectionStart = selectionStart + pastedText.length; + textarea.selectionEnd = textarea.selectionStart; + }; + + if (contentEditableDiv.html().length > 0) { + // If the image wasn't the only pasted content we just give up and + // fall back to the original pasted text. + contentEditableDiv.find("br").replaceWith("\n"); + restoreSelection(contentEditableDiv.text()); + } else { + // Depending on how the image is pasted in, we may get either a + // normal URL or a data URI. If we get a data URI we can convert it + // to a Blob and upload that, but if it is a regular URL that + // operation is prevented for security purposes. When we get a regular + // URL let's just create an tag for the image. + const imageSrc = pastedImg.attr('src'); + + if (imageSrc.match(/^data:image/)) { + // Restore the cursor position, and remove any selected text. + restoreSelection(""); + + // Create a Blob to upload. + const image = new Image(); + image.onload = function() { + // Create a new canvas. + const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); + canvas.height = image.height; + canvas.width = image.width; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0); + + canvas.toBlob(blob => this.$().fileupload('add', {files: blob})); + }; + image.src = imageSrc; + } else { + restoreSelection(""); + } + } + + contentEditableDiv.html(''); + }, 100); + } + }); + } + }, + + @on('willDestroyElement') + _unbindUploadTarget() { + this.$(".mobile-file-upload").off("click.uploader"); + this.messageBus.unsubscribe("/uploads/composer"); + const $uploadTarget = this.$(); + try { $uploadTarget.fileupload("destroy"); } + catch (e) { /* wasn't initialized yet */ } + $uploadTarget.off(); + }, + + @on('willDestroyElement') + _composerClosed() { + Ember.run.next(() => { + $('#main-outlet').css('padding-bottom', 0); + // need to wait a bit for the "slide down" transition of the composer + Ember.run.later(() => this.appEvents.trigger("composer:closed"), 400); + }); + }, + + actions: { + importQuote(toolbarEvent) { + this.sendAction('importQuote', toolbarEvent); + }, + + cancelUpload() { + if (this._xhr) { + this._xhr._userCancelled = true; + this._xhr.abort(); + this._resetUpload(); + } + this._resetUpload(); + }, + + showOptions() { + const myPos = this.$().position(); + const buttonPos = this.$('.options').position(); + + this.sendAction('showOptions', { position: "absolute", + left: myPos.left + buttonPos.left, + top: myPos.top + buttonPos.top }); + }, + + showUploadModal(toolbarEvent) { + this.sendAction('showUploadSelector', toolbarEvent); + }, + + togglePreview() { + this.toggleProperty('showPreview'); + this.keyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); + }, + + extraButtons(toolbar) { + toolbar.addButton({ + id: 'quote', + group: 'fontStyles', + icon: 'comment-o', + sendAction: 'importQuote', + title: 'composer.quote_post_title', + unshift: true + }); + + toolbar.addButton({ + id: 'upload', + group: 'insertions', + icon: 'upload', + title: 'upload', + sendAction: 'showUploadModal' + }); + + if (this.get('canWhisper')) { + toolbar.addButton({ + id: 'options', + group: 'extras', + icon: 'gear', + title: 'composer.options', + sendAction: 'showOptions' + }); + } + }, + + previewUpdated($preview) { + // Paint mentions + const unseen = linkSeenMentions($preview, this.siteSettings); + if (unseen.length) { + Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500); + } + + const post = this.get('composer.post'); + let refresh = false; + + // If we are editing a post, we'll refresh its contents once. This is a feature that + // allows a user to refresh its contents once. + if (post && !post.get('refreshedPost')) { + refresh = true; + post.set('refreshedPost', true); + } + + // Paint oneboxes + $('a.onebox', $preview).each((i, e) => Discourse.Onebox.load(e, refresh)); + this.trigger('previewRefreshed', $preview); + }, + } +}); diff --git a/app/assets/javascripts/discourse/components/composer-text-area.js.es6 b/app/assets/javascripts/discourse/components/composer-text-area.js.es6 deleted file mode 100644 index 37ac9b1cd9..0000000000 --- a/app/assets/javascripts/discourse/components/composer-text-area.js.es6 +++ /dev/null @@ -1,15 +0,0 @@ -export default Ember.TextArea.extend({ - classNameBindings: [':wmd-input'], - - placeholder: function() { - return I18n.t('composer.reply_placeholder'); - }.property('placeholderKey'), - - _signalParentInsert: function() { - this.get('parentView').childDidInsertElement(this); - }.on('didInsertElement'), - - _signalParentDestroy: function() { - this.get('parentView').childWillDestroyElement(this); - }.on('willDestroyElement') -}); diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 new file mode 100644 index 0000000000..6dc5f6e56b --- /dev/null +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -0,0 +1,29 @@ +import { default as computed, on } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['title-input'], + + @on('didInsertElement') + _focusOnTitle() { + if (!this.capabilities.touch) { + this.$('input').putCursorAtEnd(); + } + }, + + @computed('composer.titleLength', 'composer.missingTitleCharacters', 'composer.minimumTitleLength', 'lastValidatedAt') + validation(titleLength, missingTitleChars, minimumTitleLength, lastValidatedAt) { + + let reason; + if (titleLength < 1) { + reason = I18n.t('composer.error.title_missing'); + } else if (missingTitleChars > 0) { + reason = I18n.t('composer.error.title_too_short', {min: minimumTitleLength}); + } else if (titleLength > this.siteSettings.max_topic_title_length) { + reason = I18n.t('composer.error.title_too_long', {max: this.siteSettings.max_topic_title_length}); + } + + if (reason) { + return Discourse.InputValidation.create({ failed: true, reason, lastShownAt: lastValidatedAt }); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 4d26eb3e43..9c2452f38a 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -1,6 +1,6 @@ /*global Mousetrap:true */ import loadScript from 'discourse/lib/load-script'; -import { default as property, on } from 'ember-addons/ember-computed-decorators'; +import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators'; import { showSelector } from "discourse/lib/emoji/emoji-toolbar"; // Our head can be a static string or a function that returns a string @@ -111,26 +111,42 @@ Toolbar.prototype.addButton = function(button) { 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'; - createdButton.title = `${title} (${mod}+${button.shortcut})`; + var shortcutTitle = `${mod}+${button.shortcut}`; // Mac users are used to glyphs for shortcut keys if (mac) { - createdButton.title = createdButton.title.replace('Shift', "\u21E7") - .replace('Meta', "\u2318") - .replace('Alt', "\u2325") - .replace(/\+/g, ''); + 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; } - g.buttons.push(createdButton); + if (button.unshift) { + g.buttons.unshift(createdButton); + } else { + g.buttons.push(createdButton); + } }; export function onToolbarCreate(func) { @@ -144,9 +160,16 @@ export default Ember.Component.extend({ link: '', lastSel: null, + @computed('placeholder') + placeholderTranslated(placeholder) { + if (placeholder) return I18n.t(placeholder); + return null; + }, + @on('didInsertElement') _startUp() { this._applyEmojiAutocomplete(); + loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true)); const shortcuts = this.get('toolbar.shortcuts'); @@ -154,30 +177,58 @@ export default Ember.Component.extend({ const button = shortcuts[sc]; Mousetrap(this.$('.d-editor-input')[0]).bind(sc, () => { this.send(button.action, button); + return false; }); }); + + // disable clicking on links in the preview + this.$('.d-editor-preview').on('click.preview', e => { + e.preventDefault(); + return false; + }); + + this.appEvents.on('composer:insert-text', text => { + this._addText(this._getSelected(), text); + }); }, @on('willDestroyElement') _shutDown() { + this.appEvents.off('composer:insert-text'); + Ember.keys(this.get('toolbar.shortcuts')).forEach(sc => { Mousetrap(this.$('.d-editor-input')[0]).unbind(sc); }); + this.$('.d-editor-preview').off('click.preview'); }, - @property + @computed toolbar() { const toolbar = new Toolbar(); _createCallbacks.forEach(cb => cb(toolbar)); + this.sendAction('extraButtons', toolbar); return toolbar; }, - @property('ready', 'value') - preview(ready, value) { - if (!ready) { return; } + _updatePreview() { + const value = this.get('value'); + const markdownOptions = this.get('markdownOptions') || {}; + markdownOptions.sanitize = true; - const text = Discourse.Dialect.cook(value || "", {sanitize: true}); - return text ? text : ""; + this.set('preview', Discourse.Dialect.cook(value || "", markdownOptions)); + Ember.run.scheduleOnce('afterRender', () => { + if (this._state !== "inDOM") { return; } + const $preview = this.$('.d-editor-preview'); + if ($preview.length === 0) return; + + this.sendAction('previewUpdated', $preview); + }); + }, + + @observes('ready', 'value') + _watchForChanges() { + if (!this.get('ready')) { return; } + Ember.run.debounce(this, this._updatePreview, 30); }, _applyEmojiAutocomplete() { @@ -198,7 +249,7 @@ export default Ember.Component.extend({ showSelector({ appendTo: self.$(), container, - onSelect: title => self._addText(this._getSelected(), `${title}:`) + onSelect: title => self._addText(self._getSelected(), `${title}:`) }); return ""; } @@ -236,12 +287,8 @@ export default Ember.Component.extend({ if (!this.get('ready')) { return; } const textarea = this.$('textarea.d-editor-input')[0]; - let start = textarea.selectionStart; - let end = textarea.selectionEnd; - - if (start === end) { - start = end = textarea.value.length; - } + const start = textarea.selectionStart; + const end = textarea.selectionEnd; const value = textarea.value.substring(start, end); const pre = textarea.value.slice(0, start); @@ -253,7 +300,6 @@ export default Ember.Component.extend({ _selectText(from, length) { Ember.run.scheduleOnce('afterRender', () => { const textarea = this.$('textarea.d-editor-input')[0]; - textarea.focus(); textarea.selectionStart = from; textarea.selectionEnd = textarea.selectionStart + length; }); @@ -334,17 +380,24 @@ export default Ember.Component.extend({ const insert = `${sel.pre}${text}`; this.set('value', `${insert}${sel.post}`); this._selectText(insert.length, 0); + Ember.run.once("afterRender", () => { $("textarea.d-editor-input").focus(); } ); }, actions: { toolbarButton(button) { const selected = this._getSelected(); - button.perform({ + const toolbarEvent = { selected, applySurround: (head, tail, exampleKey) => this._applySurround(selected, head, tail, exampleKey), applyList: (head, exampleKey) => this._applyList(selected, head, exampleKey), addText: text => this._addText(selected, text) - }); + }; + + if (button.sendAction) { + return this.sendAction(button.sendAction, toolbarEvent); + } else { + button.perform(toolbarEvent); + } }, showLinkModal() { @@ -362,7 +415,8 @@ export default Ember.Component.extend({ const remaining = link.replace(m[0], ''); this._addText(this._lastSel, `[${description}](${remaining})`); } else { - this._addText(this._lastSel, `[${link}](${link})`); + const selectedValue = this._lastSel.value || link; + this._addText(this._lastSel, `[${selectedValue}](${link})`); } this.set('link', ''); diff --git a/app/assets/javascripts/discourse/components/d-link.js.es6 b/app/assets/javascripts/discourse/components/d-link.js.es6 index c8fda35c20..533d80c6ee 100644 --- a/app/assets/javascripts/discourse/components/d-link.js.es6 +++ b/app/assets/javascripts/discourse/components/d-link.js.es6 @@ -21,7 +21,7 @@ export default Ember.Component.extend({ params.push(model); } - return router.router.generate.apply(router.router, params); + return Discourse.getURL(router.router.generate.apply(router.router, params)); } } diff --git a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 index 34419b477b..6135d4b545 100644 --- a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 @@ -3,10 +3,7 @@ import { buildCategoryPanel } from 'discourse/components/edit-category-panel'; export default buildCategoryPanel('topic-template', { _activeTabChanged: function() { if (this.get('activeTab')) { - const self = this; - Ember.run.schedule('afterRender', function() { - self.$('.wmd-input').focus(); - }); + Ember.run.scheduleOnce('afterRender', () => this.$('.d-editor-input').focus()); } }.observes('activeTab') }); diff --git a/app/assets/javascripts/discourse/components/image-uploader.js.es6 b/app/assets/javascripts/discourse/components/image-uploader.js.es6 index 3fc6650a47..08359fa869 100644 --- a/app/assets/javascripts/discourse/components/image-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/image-uploader.js.es6 @@ -1,10 +1,10 @@ -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; import UploadMixin from "discourse/mixins/upload"; export default Em.Component.extend(UploadMixin, { classNames: ["image-uploader"], - @property('imageUrl') + @computed('imageUrl') backgroundStyle(imageUrl) { if (Em.isNone(imageUrl)) { return; } return `background-image: url(${imageUrl})`.htmlSafe(); diff --git a/app/assets/javascripts/discourse/components/menu-panel.js.es6 b/app/assets/javascripts/discourse/components/menu-panel.js.es6 index 5fb1e034ab..c35a77923a 100644 --- a/app/assets/javascripts/discourse/components/menu-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/menu-panel.js.es6 @@ -2,7 +2,7 @@ import { default as computed, on, observes } from 'ember-addons/ember-computed-d import { headerHeight } from 'discourse/views/header'; const PANEL_BODY_MARGIN = 30; -const mutationSupport = !!window['MutationObserver']; +const mutationSupport = !Ember.testing && !!window['MutationObserver']; export default Ember.Component.extend({ classNameBindings: [':menu-panel', 'visible::hidden', 'viewMode'], @@ -24,13 +24,19 @@ export default Ember.Component.extend({ const $panelBody = this.$('.panel-body'); let contentHeight = parseInt(this.$('.panel-body-contents').height()); + // We use a mutationObserver to check for style changes, so it's important + // we don't set it if it doesn't change. Same goes for the $panelBody! + const style = this.$().prop('style'); + if (viewMode === 'drop-down') { const $buttonPanel = $('header ul.icons'); if ($buttonPanel.length === 0) { return; } // These values need to be set here, not in the css file - this is to deal with the // possibility of the window being resized and the menu changing from .slide-in to .drop-down. - this.$().css({ top: '100%', height: 'auto' }); + if (style.top !== '100%' || style.height !== 'auto') { + this.$().css({ top: '100%', height: 'auto' }); + } // adjust panel height const fullHeight = parseInt($window.height()); @@ -40,7 +46,9 @@ export default Ember.Component.extend({ if (contentHeight + (offsetTop - scrollTop) + PANEL_BODY_MARGIN > fullHeight) { contentHeight = fullHeight - (offsetTop - scrollTop) - PANEL_BODY_MARGIN; } - $panelBody.height(contentHeight); + if ($panelBody.height() !== contentHeight) { + $panelBody.height(contentHeight); + } $('body').addClass('drop-down-visible'); } else { const menuTop = headerHeight(); @@ -53,8 +61,12 @@ export default Ember.Component.extend({ height = winHeight - menuTop; } - $panelBody.height('100%'); - this.$().css({ top: menuTop + "px", height }); + if ($panelBody.prop('style').height !== '100%') { + $panelBody.height('100%'); + } + if (style.top !== menuTop + "px" || style.height !== height) { + this.$().css({ top: menuTop + "px", height }); + } $('body').removeClass('drop-down-visible'); } @@ -127,7 +139,7 @@ export default Ember.Component.extend({ _watchSizeChanges() { if (mutationSupport) { this._observer.disconnect(); - this._observer.observe(this.element, { childList: true, subtree: true }); + this._observer.observe(this.element, { childList: true, subtree: true, characterData: true, attributes: true }); } else { clearInterval(this._resizeInterval); this._resizeInterval = setInterval(() => { @@ -176,7 +188,7 @@ export default Ember.Component.extend({ if (mutationSupport) { this._observer = new MutationObserver(() => { - Ember.run(() => this.performLayout()); + Ember.run.debounce(this, this.performLayout, 50); }); } diff --git a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 index 51725abc09..c63997674c 100644 --- a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 +++ b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 @@ -1,24 +1,30 @@ import StringBuffer from 'discourse/mixins/string-buffer'; import { iconHTML } from 'discourse/helpers/fa-icon'; -import { observes } from 'ember-addons/ember-computed-decorators'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend(StringBuffer, { - classNameBindings: [':popup-tip', 'good', 'bad', 'shownAt::hide'], + classNameBindings: [':popup-tip', 'good', 'bad', 'lastShownAt::hide'], animateAttribute: null, bouncePixels: 6, bounceDelay: 100, rerenderTriggers: ['validation.reason'], click() { - this.set('shownAt', false); + this.set('shownAt', null); + this.set('validation.lastShownAt', null); }, bad: Ember.computed.alias("validation.failed"), good: Ember.computed.not("bad"), - @observes("shownAt") + @computed('shownAt', 'validation.lastShownAt') + lastShownAt(shownAt, lastShownAt) { + return shownAt || lastShownAt; + }, + + @observes('lastShownAt') bounce() { - if (this.get("shownAt")) { + if (this.get("lastShownAt")) { var $elem = this.$(); if (!this.animateAttribute) { this.animateAttribute = $elem.css('left') === 'auto' ? 'right' : 'left'; @@ -35,8 +41,7 @@ export default Ember.Component.extend(StringBuffer, { const reason = this.get('validation.reason'); if (!reason) { return; } - buffer.push("" + iconHTML('times-circle') + ""); - buffer.push(reason); + buffer.push(`${iconHTML('times-circle')}${reason}`); }, bounceLeft($elem) { diff --git a/app/assets/javascripts/discourse/components/post-gutter.js.es6 b/app/assets/javascripts/discourse/components/post-gutter.js.es6 index f4ca4cac6f..c545f1c0b7 100644 --- a/app/assets/javascripts/discourse/components/post-gutter.js.es6 +++ b/app/assets/javascripts/discourse/components/post-gutter.js.es6 @@ -2,7 +2,7 @@ const MAX_SHOWN = 5; import StringBuffer from 'discourse/mixins/string-buffer'; import { iconHTML } from 'discourse/helpers/fa-icon'; -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; const { get, isEmpty, Component } = Ember; @@ -12,7 +12,7 @@ export default Component.extend(StringBuffer, { rerenderTriggers: ['expanded'], // Roll up links to avoid duplicates - @property('links') + @computed('links') collapsed(links) { const seen = {}; const result = []; diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index b8328a87f3..92b51cc959 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -1,9 +1,8 @@ -import { setting } from 'discourse/lib/computed'; import DiscourseURL from 'discourse/lib/url'; import Quote from 'discourse/lib/quote'; import Draft from 'discourse/models/draft'; import Composer from 'discourse/models/composer'; -import computed from 'ember-addons/ember-computed-decorators'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; function loadDraft(store, opts) { opts = opts || {}; @@ -50,17 +49,17 @@ export default Ember.Controller.extend({ showEditReason: false, editReason: null, - maxTitleLength: setting('max_topic_title_length'), scopedCategoryId: null, similarTopics: null, similarTopicsMessage: null, lastSimilaritySearch: null, optionsVisible: false, - topic: null, + lastValidatedAt: null, - // TODO: Remove this, very bad - view: null, + isUploading: false, + + topic: null, _initializeSimilar: function() { this.set('similarTopics', []); @@ -109,7 +108,7 @@ export default Ember.Controller.extend({ }, // Import a quote from the post - importQuote() { + importQuote(toolbarEvent) { const postStream = this.get('topic.postStream'); let postId = this.get('model.post.id'); @@ -135,7 +134,7 @@ export default Ember.Controller.extend({ return this.store.find('post', postId).then(function(post) { const quote = Quote.build(post, post.get("raw"), {raw: true, full: true}); - composer.appendBlockAtCursor(quote); + toolbarEvent.addText(quote); composer.set('model.loading', false); }); } @@ -173,39 +172,10 @@ export default Ember.Controller.extend({ }, - appendText(text, opts) { - const c = this.get('model'); - if (c) { - opts = opts || {}; - const wmd = $('.wmd-input'), - val = wmd.val() || '', - position = opts.position === "cursor" ? wmd.caret() : val.length, - caret = c.appendText(text, position, opts); - - if (wmd[0]) { - Em.run.next(() => Discourse.Utilities.setCaretPosition(wmd[0], caret)); - } - } - }, - - appendTextAtCursor(text, opts) { - opts = opts || {}; - opts.position = "cursor"; - this.appendText(text, opts); - }, - - appendBlockAtCursor(text, opts) { - opts = opts || {}; - opts.position = "cursor"; - opts.block = true; - this.appendText(text, opts); - }, - categories: function() { return Discourse.Category.list(); }.property(), - toggle() { this.closeAutocomplete(); switch (this.get('model.composeState')) { @@ -225,7 +195,7 @@ export default Ember.Controller.extend({ return false; }, - disableSubmit: Ember.computed.or("model.loading", "view.isUploading"), + disableSubmit: Ember.computed.or("model.loading", "isUploading"), save(force) { const composer = this.get('model'); @@ -237,12 +207,7 @@ export default Ember.Controller.extend({ } if (composer.get('cantSubmitPost')) { - const now = Date.now(); - this.setProperties({ - 'view.showTitleTip': now, - 'view.showCategoryTip': now, - 'view.showReplyTip': now - }); + this.set('lastValidatedAt', Date.now()); return; } @@ -291,10 +256,18 @@ export default Ember.Controller.extend({ var staged = false; const disableJumpReply = Discourse.User.currentProp('disable_jump_reply'); - const promise = composer.save({ - imageSizes: this.get('view').imageSizes(), - editReason: this.get("editReason") - }).then(function(result) { + // TODO: This should not happen in model + const imageSizes = {}; + $('#reply-control .d-editor-preview img').each((i, e) => { + const $img = $(e); + const src = $img.prop('src'); + + if (src && src.length) { + imageSizes[src] = { width: $img.width(), height: $img.height() }; + } + }); + + const promise = composer.save({ imageSizes, editReason: this.get("editReason")}).then(function(result) { if (result.responseJson.action === "enqueued") { self.send('postWasEnqueued', result.responseJson); self.destroyDraft(); @@ -366,8 +339,8 @@ export default Ember.Controller.extend({ // We don't care about similar topics unless creating a topic if (!this.get('model.creatingTopic')) { return; } - let body = this.get('model.reply'); - const title = this.get('model.title'); + let body = this.get('model.reply') || ''; + const title = this.get('model.title') || ''; // Ensure the fields are of the minimum length if (body.length < Discourse.SiteSettings.min_body_similar_length) { return; } @@ -405,11 +378,6 @@ export default Ember.Controller.extend({ }); }, - saveDraft() { - const model = this.get('model'); - if (model) { model.saveDraft(); } - }, - /** Open the composer view @@ -502,7 +470,7 @@ export default Ember.Controller.extend({ composerModel.set('composeState', Discourse.Composer.OPEN); composerModel.set('isWarning', false); - if (opts.topicTitle && opts.topicTitle.length <= this.get('maxTitleLength')) { + if (opts.topicTitle && opts.topicTitle.length <= this.siteSettings.max_topic_title_length) { this.set('model.title', opts.topicTitle); } @@ -572,7 +540,6 @@ export default Ember.Controller.extend({ }); }, - shrink() { if (this.get('model.replyDirty')) { this.collapse(); @@ -581,22 +548,34 @@ export default Ember.Controller.extend({ } }, + _saveDraft() { + const model = this.get('model'); + if (model) { model.saveDraft(); }; + }, + + @observes('model.reply', 'model.title') + _shouldSaveDraft() { + Ember.run.debounce(this, this._saveDraft, 2000); + }, + + @computed('model.categoryId', 'lastValidatedAt') + categoryValidation(categoryId, lastValidatedAt) { + if( !this.siteSettings.allow_uncategorized_topics && !categoryId) { + return Discourse.InputValidation.create({ failed: true, reason: I18n.t('composer.error.category_missing'), lastShownAt: lastValidatedAt }); + } + }, + collapse() { - this.saveDraft(); + this._saveDraft(); this.set('model.composeState', Discourse.Composer.DRAFT); }, close() { - this.setProperties({ - model: null, - 'view.showTitleTip': false, - 'view.showCategoryTip': false, - 'view.showReplyTip': false - }); + this.setProperties({ model: null, lastValidatedAt: null }); }, closeAutocomplete() { - $('.wmd-input').autocomplete({ cancel: true }); + $('.d-editor-input').autocomplete({ cancel: true }); }, showOptions() { diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index 956cbe2357..c387e53321 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -122,12 +122,10 @@ export default Ember.Controller.extend(ModalFunctionality, { this.fetchUserDetails(); }.observes('model.username'), - fetchUserDetails: function() { - if( Discourse.User.currentProp('staff') && this.get('model.username') ) { - const flagController = this; - Discourse.AdminUser.find(this.get('model.username').toLowerCase()).then(function(user){ - flagController.set('userDetails', user); - }); + fetchUserDetails() { + if (Discourse.User.currentProp('staff') && this.get('model.username')) { + Discourse.AdminUser.find(this.get('model.username').toLowerCase()) + .then(user => this.set('userDetails', user)); } } diff --git a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 index 173b14cb50..a4208806a7 100644 --- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 @@ -111,7 +111,6 @@ export default Ember.Controller.extend({ @computed('q') showLikeCount(q) { - console.log(q); return q && q.indexOf("order:likes") > -1; }, diff --git a/app/assets/javascripts/discourse/controllers/group/members.js.es6 b/app/assets/javascripts/discourse/controllers/group/members.js.es6 index a22c82705a..3234c1c213 100644 --- a/app/assets/javascripts/discourse/controllers/group/members.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group/members.js.es6 @@ -1,9 +1,33 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; + export default Ember.Controller.extend({ loading: false, limit: null, offset: null, + isOwner: function() { + if (this.get('currentUser.admin')) { + return true; + } + const owners = this.get('model.owners'); + const currentUserId = this.get('currentUser.id'); + if (currentUserId) { + return !!owners.findBy('id', currentUserId); + } + }.property('model.owners.@each'), + actions: { + removeMember(user) { + this.get('model').removeMember(user); + }, + + addMembers() { + const usernames = this.get('usernames'); + if (usernames && usernames.length > 0) { + this.get('model').addMembers(usernames).then(() => this.set('usernames', [])).catch(popupAjaxError); + } + }, + loadMore() { if (this.get("loading")) { return; } // we've reached the end diff --git a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 index 620decb81d..2ad413846d 100644 --- a/app/assets/javascripts/discourse/controllers/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/controllers/quote-button.js.es6 @@ -1,6 +1,6 @@ import loadScript from 'discourse/lib/load-script'; import Quote from 'discourse/lib/quote'; -import property from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ needs: ['topic', 'composer'], @@ -9,7 +9,7 @@ export default Ember.Controller.extend({ loadScript('defer/html-sanitizer-bundle'); }.on('init'), - @property('buffer', 'postId') + @computed('buffer', 'postId') post(buffer, postId) { if (!postId || Ember.isEmpty(buffer)) { return null; } @@ -135,7 +135,7 @@ export default Ember.Controller.extend({ const quotedText = Quote.build(post, buffer); composerOpts.quote = quotedText; if (composerController.get('content.viewOpen') || composerController.get('content.viewDraft')) { - composerController.appendBlockAtCursor(quotedText.trim()); + this.appEvents.trigger('composer:insert-text', quotedText.trim()); } else { composerController.open(composerOpts); } diff --git a/app/assets/javascripts/discourse/controllers/topic-progress.js.es6 b/app/assets/javascripts/discourse/controllers/topic-progress.js.es6 index 39e9ff2b7e..324d3b7357 100644 --- a/app/assets/javascripts/discourse/controllers/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic-progress.js.es6 @@ -7,7 +7,7 @@ export default Ember.Controller.extend({ toPostIndex: null, actions: { - toggleExpansion: function(opts) { + toggleExpansion(opts) { this.toggleProperty('expanded'); if (this.get('expanded')) { this.set('toPostIndex', this.get('progressPosition')); @@ -20,7 +20,7 @@ export default Ember.Controller.extend({ } }, - jumpPost: function() { + jumpPost() { var postIndex = parseInt(this.get('toPostIndex'), 10); // Validate the post index first @@ -52,17 +52,17 @@ export default Ember.Controller.extend({ } }, - jumpTop: function() { + jumpTop() { this.jumpTo(this.get('model.firstPostUrl')); }, - jumpBottom: function() { + jumpBottom() { this.jumpTo(this.get('model.lastPostUrl')); } }, // Route and close the expansion - jumpTo: function(url) { + jumpTo(url) { this.set('expanded', false); DiscourseURL.routeTo(url); }, diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index e0e480953f..fb9eb92e9e 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -3,7 +3,6 @@ import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; import Topic from 'discourse/models/topic'; import Quote from 'discourse/lib/quote'; -import { setting } from 'discourse/lib/computed'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import computed from 'ember-addons/ember-computed-decorators'; @@ -24,8 +23,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'), isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"), - maxTitleLength: setting('max_topic_title_length'), - _titleChanged: function() { const title = this.get('model.title'); if (!Ember.isEmpty(title)) { @@ -106,7 +103,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { composerController.get('content.action') === Discourse.Composer.REPLY) { composerController.set('content.post', post); composerController.set('content.composeState', Discourse.Composer.OPEN); - composerController.appendText(quotedText); + this.appEvents.trigger('composer:insert-text', quotedText.trim()); } else { const opts = { @@ -398,9 +395,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }).then(() => { return Em.isEmpty(quotedText) ? Discourse.Post.loadQuote(post.get('id')) : quotedText; }).then(q => { - const postUrl = `${location.protocol}//${location.host}${post.get('url')}`, - postLink = `[${Handlebars.escapeExpression(self.get('model.title'))}](${postUrl})`; - composerController.appendText(`${I18n.t("post.continue_discussion", { postLink })}\n\n${q}`); + const postUrl = `${location.protocol}//${location.host}${post.get('url')}`; + const postLink = `[${Handlebars.escapeExpression(self.get('model.title'))}](${postUrl})`; + + this.appEvents.trigger('composer:insert-text', `${I18n.t("post.continue_discussion", { postLink })}\n\n${q}`); }); }, @@ -631,20 +629,27 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }.observes('model.currentPost'), readPosts(topicId, postNumbers) { - const postStream = this.get('model.postStream'); + const topic = this.get("model"), + postStream = topic.get("postStream"); - if (postStream.get('topic.id') === topicId){ - _.each(postStream.get('posts'), function(post){ - // optimise heavy loop - // TODO identity map for postNumber - if(_.include(postNumbers,post.post_number) && !post.read){ + 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); } }); const max = _.max(postNumbers); - if(max > this.get('model.last_read_post_number')){ - this.set('model.last_read_post_number', max); + if (max > topic.get("last_read_post_number")) { + topic.set("last_read_post_number", max); + } + + if (this.siteSettings.automatically_unpin_topics && this.currentUser) { + // automatically unpin topics when the user reaches the bottom + if (topic.get("pinned") && max >= topic.get("highest_post_number")) { + Em.run.next(() => topic.clearPin()); + } } } }, diff --git a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 index f0a7d67fcd..8f3b5a5e58 100644 --- a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 +++ b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 @@ -1,14 +1,58 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export function uploadTranslate(key, options) { + options = options || {}; + if (Discourse.Utilities.allowsAttachments()) { key += "_with_attachments"; } + return I18n.t(`upload_selector.${key}`, options); +} export default Ember.Controller.extend(ModalFunctionality, { showMore: false, local: true, + imageUrl: null, + imageLink: null, remote: Ember.computed.not("local"), + @computed + uploadIcon() { + return Discourse.Utilities.allowsAttachments() ? "upload" : "picture-o"; + }, + + @computed('controller.local') + tip(local) { + const source = local ? "local" : "remote"; + const authorized_extensions = Discourse.Utilities.authorizesAllExtensions() ? "" : `(${Discourse.Utilities.authorizedExtensions()})`; + return uploadTranslate(`${source}_tip`, { authorized_extensions }); + }, + actions: { - useLocal() { this.setProperties({ local: true, showMore: false}); }, - useRemote() { this.set("local", false); }, - toggleShowMore() { this.toggleProperty("showMore"); } + upload() { + if (this.get('local')) { + $('.wmd-controls').fileupload('add', { fileInput: $('#filename-input') }); + } else { + const imageUrl = this.get('imageUrl') || ''; + const imageLink = this.get('imageLink') || ''; + const toolbarEvent = this.get('toolbarEvent'); + + if (this.get('showMore') && imageLink.length > 3) { + toolbarEvent.addText(`[![](${imageUrl})](${imageLink})`); + } else { + toolbarEvent.addText(imageUrl); + } + } + this.send('closeModal'); + }, + + useLocal() { + this.setProperties({ local: true, showMore: false}); + }, + useRemote() { + this.set("local", false); + }, + toggleShowMore() { + this.toggleProperty("showMore"); + } } }); diff --git a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js index 816ed3934b..079bb014ac 100644 --- a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js +++ b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js @@ -133,7 +133,6 @@ Discourse.Markdown.whiteListTag('span', 'class', /^bbcode-[bius]$/); Discourse.BBCode.replaceBBCode('ul', function(contents) { return ['ul'].concat(Discourse.BBCode.removeEmptyLines(contents)); }); Discourse.BBCode.replaceBBCode('ol', function(contents) { return ['ol'].concat(Discourse.BBCode.removeEmptyLines(contents)); }); Discourse.BBCode.replaceBBCode('li', function(contents) { return ['li'].concat(Discourse.BBCode.removeEmptyLines(contents)); }); -Discourse.BBCode.replaceBBCode('spoiler', function(contents) { return ['span', {'class': 'spoiler'}].concat(contents); }); Discourse.BBCode.rawBBCode('img', function(contents) { return ['img', {href: contents}]; }); Discourse.BBCode.rawBBCode('email', function(contents) { return ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]; }); diff --git a/app/assets/javascripts/discourse/dialects/mention_dialect.js b/app/assets/javascripts/discourse/dialects/mention_dialect.js index 4f1b8b8da1..9579f04afe 100644 --- a/app/assets/javascripts/discourse/dialects/mention_dialect.js +++ b/app/assets/javascripts/discourse/dialects/mention_dialect.js @@ -7,7 +7,7 @@ Discourse.Dialect.inlineRegexp({ start: '@', // NOTE: we really should be using SiteSettings here, but it loads later in process // also, if we do, we must ensure serverside version works as well - matcher: /^(@[A-Za-z0-9][A-Za-z0-9_\.\-]{0,40}[A-Za-z0-9])/, + matcher: /^(@[A-Za-z0-9][A-Za-z0-9_\.\-]{0,40}[A-Za-z0-9\_])/, wordBoundary: true, emitter: function(matches) { diff --git a/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 index d9e76878b0..3df8b18c56 100644 --- a/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 +++ b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 @@ -81,14 +81,7 @@ function findOutlets(collection, callback) { } } - const dashedName = outletName.replace(/_/g, '-'); - if (dashedName !== outletName) { - Ember.warn("DEPRECATION: You need to use dashes in outlet names, not underscores"); - callback(dashedName, res, uniqueName); - } else { - callback(outletName, res, uniqueName); - } - + callback(outletName, res, uniqueName); } }); } diff --git a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 index 5f23126b77..1d62753988 100644 --- a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 +++ b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 @@ -1,4 +1,3 @@ -import { showSelector } from "discourse/lib/emoji/emoji-toolbar"; import { onToolbarCreate } from 'discourse/components/d-editor'; export default { @@ -6,32 +5,20 @@ export default { initialize(container) { const siteSettings = container.lookup('site-settings:main'); - if (siteSettings.enable_emoji) { + if (siteSettings.enable_emoji) { onToolbarCreate(toolbar => { toolbar.addButton({ id: 'emoji', group: 'extras', icon: 'smile-o', action: 'emoji', - shortcut: 'Alt+E', title: 'composer.emoji' }); }); - window.PagedownCustom.appendButtons.push({ - id: 'wmd-emoji-button', - description: I18n.t("composer.emoji"), - execute() { - showSelector({ - container, - onSelect(title) { - const composerController = container.lookup('controller:composer'); - composerController.appendTextAtCursor(`:${title}:`, {space: true}); - }, - }); - } - }); + // enable plugin emojis + Discourse.Emoji.applyCustomEmojis(); } } }; 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 012fc98135..746abc38f4 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 @@ -18,6 +18,6 @@ export default { const style = 'max-width:' + width + 'px;' + 'max-height:' + height + 'px;'; - $('').appendTo('head'); + $('').appendTo('head'); } }; diff --git a/app/assets/javascripts/discourse/initializers/lab-deprecation.js.es6 b/app/assets/javascripts/discourse/initializers/lab-deprecation.js.es6 deleted file mode 100644 index 1f337933fc..0000000000 --- a/app/assets/javascripts/discourse/initializers/lab-deprecation.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -import loadScript from 'discourse/lib/load-script'; - -export default { - name: 'lab-deprecation', - - initialize() { - if (window.$LAB) { return; } - - window.$LAB = { - script(path) { - Ember.warn('$LAB is not included with Discouse anymore. Use `loadScript` instead.'); - - const promise = loadScript(path); - promise.wait = promise.then; - return promise; - } - }; - } -}; diff --git a/app/assets/javascripts/discourse/initializers/message-bus.js.es6 b/app/assets/javascripts/discourse/initializers/message-bus.js.es6 index be9de5c326..c6536213ac 100644 --- a/app/assets/javascripts/discourse/initializers/message-bus.js.es6 +++ b/app/assets/javascripts/discourse/initializers/message-bus.js.es6 @@ -11,14 +11,6 @@ export default { user = container.lookup('current-user:main'), siteSettings = container.lookup('site-settings:main'); - const deprecatedBus = {}; - deprecatedBus.prototype = messageBus; - deprecatedBus.subscribe = function() { - Ember.warn("Discourse.MessageBus is deprecated. Use `this.messageBus` instead"); - messageBus.subscribe.apply(messageBus, Array.prototype.slice(arguments)); - }; - Discourse.MessageBus = deprecatedBus; - messageBus.alwaysLongPoll = Discourse.Environment === "development"; messageBus.start(); diff --git a/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 b/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 index 803717002c..33a2e389a4 100644 --- a/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 +++ b/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 @@ -4,14 +4,6 @@ export default { name: 'sharing-sources', initialize: function() { - // Backwards compatibility - Discourse.ShareLink = {}; - Discourse.ShareLink.addTarget = function(id, source) { - Ember.warn('Discourse.ShareLink.addTarget is deprecated. Import `Sharing` and call `addSource` instead.'); - source.id = id; - Sharing.addSource(source); - }; - Sharing.addSource({ id: 'twitter', faIcon: 'fa-twitter-square', diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js deleted file mode 100644 index 2dd0acaef3..0000000000 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ /dev/null @@ -1,2190 +0,0 @@ -// needs Markdown.Converter.js at the moment - - -// To insert extra buttons: -// -// Before this file is required, define a PagedownCustom object. Give it an attribtue of insertButtons, which is an array -// of the buttons you want to insert. For example: -// -// window.PagedownCustom = { -// insertButtons: [ -// { -// id: 'wmd-bark', -// description: 'Bark', -// execute: function() { -// return alert('woof!'); -// } -// } -// ] -// }; -// -// To extend actions: -// -// window.PagedownCustom = { -// customActions: { -// "doBlockquote": function(chunk, postProcessing, oldDoBlockquote) { -// console.log('custom blockquote called!'); -// return oldDoBlockquote.call(this, chunk, postProcessing); -// } -// } -// }; - - -(function () { - - var util = {}, - position = {}, - ui = {}, - doc = window.document, - re = window.RegExp, - nav = window.navigator, - SETTINGS = { lineLength: 72 }; - - - var defaultsStrings = { - bold: "Strong Ctrl+B", - boldexample: "strong text", - - italic: "Emphasis Ctrl+I", - italicexample: "emphasized text", - - link: "Hyperlink Ctrl+L", - linkdescription: "enter link description here", - linkdialog: "

Insert Hyperlink

http://example.com/ \"optional title\"

", - - quote: "Blockquote
Ctrl+Q", - quoteexample: "Blockquote", - - code: "Code Sample
 Ctrl+K",
-        codeexample: "enter code here",
-
-        image: "Image  Ctrl+G",
-        imagedescription: "enter image description here",
-        imagedialog: "

Insert Image

http://example.com/images/diagram.jpg \"optional title\"

Need
free image hosting?

", - - olist: "Numbered List
    Ctrl+O", - ulist: "Bulleted List
-
-
-
-
- {{conditional-loading-spinner condition=model.loading}} - {{composer-text-area tabindex="4" value=model.reply}} - {{popup-input-tip validation=view.replyValidation shownAt=view.showReplyTip}} -
- -
-
-
-
- {{#if site.mobileView}} - - {{i18n 'upload'}} - {{else}} - {{{model.toggleText}}} - {{/if}} - {{#if view.isUploading}} -
- {{loading-spinner size="small"}} {{i18n 'upload_selector.uploading'}} {{view.uploadProgress}}% {{fa-icon "times"}} -
- {{/if}} -
- {{model.draftStatus}} -
-
-
+ {{composer-editor topic=topic + composer=model + lastValidatedAt=lastValidatedAt + canWhisper=canWhisper + draftStatus=model.draftStatus + isUploading=isUploading + importQuote="importQuote" + showOptions="showOptions" + showUploadSelector="showUploadSelector"}} {{#if currentUser}}
diff --git a/app/assets/javascripts/discourse/templates/group/members.hbs b/app/assets/javascripts/discourse/templates/group/members.hbs index 8421949e04..405125993f 100644 --- a/app/assets/javascripts/discourse/templates/group/members.hbs +++ b/app/assets/javascripts/discourse/templates/group/members.hbs @@ -1,18 +1,40 @@ {{#if model}} + {{#if isOwner}} +
+
+ {{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}} + {{d-button action="addMembers" class="add" icon="plus" label="groups.add"}} +
+
+ {{/if}} + {{#if isOwner}} + + {{/if}} {{#each model.members as |m|}} - + + {{#if isOwner}} + + {{/if}} {{/each}}
{{i18n 'last_post'}} {{i18n 'last_seen'}}
{{user-small user=m}}{{user-small user=m}} + {{#if m.owner}} + {{i18n "groups.owner"}} + {{/if}} + {{bound-date m.last_posted_at}} {{bound-date m.last_seen_at}} + {{#unless m.owner}} + + {{/unless}} +
diff --git a/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs index 88dfe8f000..4e41ba326b 100644 --- a/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs @@ -1,8 +1,5 @@ {{#if view.showBadges}} {{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}} {{else}} - {{#if topic.unseen}} - - {{/if}} {{raw "list/posts-count-column" topic=topic tagName="div"}} -{{/if}} \ No newline at end of file +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs index 06af1df5d1..92a502662e 100644 --- a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs @@ -8,7 +8,9 @@