diff --git a/Gemfile.lock b/Gemfile.lock index f9a0ef20bd..28e669d349 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -46,18 +46,18 @@ GEM ember-source execjs handlebars-source (>= 1.0.0.rc.4) - better_errors (2.1.0) + better_errors (2.1.1) coderay (>= 1.0.0) erubis (>= 2.6.6) rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) builder (3.2.2) - celluloid (0.15.2) - timers (~> 1.1.0) + celluloid (0.16.0) + timers (~> 4.0.0) certified (1.0.0) coderay (1.1.0) - connection_pool (2.0.0) + connection_pool (2.1.1) crass (0.2.1) daemons (1.1.9) debug_inspector (0.0.2) @@ -81,7 +81,7 @@ GEM ember-source (1.9.0.beta.4) handlebars-source (~> 2.0) erubis (2.7.0) - eventmachine (1.0.4) + eventmachine (1.0.5) excon (0.42.1) execjs (2.2.2) exifr (1.1.3) @@ -179,10 +179,11 @@ GEM sorcerer (>= 0.3.7) guess_html_encoding (0.0.9) handlebars-source (2.0.0) - hashie (3.3.1) + hashie (3.3.2) highline (1.6.21) hike (1.2.3) hiredis (0.5.2) + hitimes (1.2.2) htmlentities (4.3.3) i18n (0.6.11) image_optim (0.9.1) @@ -198,7 +199,7 @@ GEM jquery-rails (3.1.2) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) - json (1.8.1) + json (1.8.2) jwt (1.0.0) kgio (2.9.2) librarian (0.1.2) @@ -212,20 +213,20 @@ GEM mime-types (~> 1.16) treetop (~> 1.4.8) memory_profiler (0.0.4) - message_bus (1.0.5) + message_bus (1.0.6) eventmachine rack (>= 1.1.3) redis metaclass (0.0.4) method_source (0.8.2) mime-types (1.25.1) - mini_portile (0.6.1) + mini_portile (0.6.2) minitest (5.4.2) mocha (1.1.0) metaclass (~> 0.0.1) mock_redis (0.13.2) moneta (0.8.0) - msgpack (0.5.8) + msgpack (0.5.10) multi_json (1.10.1) multi_xml (0.5.5) multipart-post (2.0.0) @@ -234,7 +235,7 @@ GEM net-ssh (>= 2.6.5) net-ssh (2.9.1) netrc (0.7.7) - nokogiri (1.6.5) + nokogiri (1.6.6.2) mini_portile (~> 0.6.0) nokogumbo (1.1.12) nokogiri @@ -245,7 +246,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (~> 1.2) - oj (2.11.1) + oj (2.11.4) omniauth (1.2.2) hashie (>= 1.2, < 4) rack (~> 1.0) @@ -271,7 +272,7 @@ GEM omniauth-twitter (1.0.1) multi_json (~> 1.3) omniauth-oauth (~> 1.0) - onebox (1.5.6) + onebox (1.5.11) moneta (~> 0.7) multi_json (~> 1.7) mustache (~> 0.99) @@ -279,7 +280,7 @@ GEM openid-redis-store (0.0.2) redis ruby-openid - pg (0.18.0) + pg (0.18.1) polyglot (0.3.5) progress (3.0.1) pry (0.10.1) @@ -290,7 +291,7 @@ GEM pry (>= 0.9.10, < 0.11.0) pry-rails (0.3.2) pry (>= 0.9.10) - puma (2.9.1) + puma (2.11.0) rack (>= 1.1, < 2.0) qunit-rails (0.0.7) railties @@ -328,7 +329,7 @@ GEM rb-fsevent (0.9.4) rb-inotify (0.9.5) ffi (>= 0.5.0) - rbtrace (0.4.5) + rbtrace (0.4.6) ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) @@ -388,9 +389,9 @@ GEM shoulda-context (1.2.1) shoulda-matchers (2.7.0) activesupport (>= 3.0.0) - sidekiq (3.2.5) - celluloid (= 0.15.2) - connection_pool (>= 2.0.0) + sidekiq (3.3.1) + celluloid (>= 0.16.0) + connection_pool (>= 2.1.1) json redis (>= 3.0.6) redis-namespace (>= 1.3.1) @@ -424,19 +425,20 @@ GEM therubyracer (0.12.1) libv8 (~> 3.16.14.0) ref - thin (1.6.2) - daemons (>= 1.0.9) - eventmachine (>= 1.0.0) - rack (>= 1.0.0) + thin (1.6.3) + daemons (~> 1.0, >= 1.0.9) + eventmachine (~> 1.0) + rack (~> 1.0) thor (0.19.1) thread_safe (0.3.4) tilt (1.4.1) timecop (0.7.1) - timers (1.1.0) + timers (4.0.1) + hitimes treetop (1.4.15) polyglot polyglot (>= 0.3.1) - trollop (2.0) + trollop (2.1.1) tzinfo (1.2.2) thread_safe (~> 0.1) uglifier (2.6.0) diff --git a/Vagrantfile b/Vagrantfile index acbd512684..54955eaa06 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -3,8 +3,8 @@ # See https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md # Vagrant.configure("2") do |config| - config.vm.box = 'discourse-0.9.9.13' - config.vm.box_url = "https://d3fvb7b7auiut8.cloudfront.net/discourse-0.9.9.13.box" + config.vm.box= "discourse/discourse-0.9.9.15.box" + config.vm.box_url = "https://vagrantcloud.com/discourse/discourse-0.9.9.15.box" # Make this VM reachable on the host network as well, so that other # VM's running other browsers can access our dev server. diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index 6bd8eda6bf..89b448b52a 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -1,7 +1,6 @@ export default Em.ObjectController.extend({ - needs: ['adminGroups'], + needs: ['adminGroupsType'], disableSave: false, - usernames: null, currentPage: function() { if (this.get("user_count") == 0) { return 0; } @@ -59,28 +58,29 @@ export default Em.ObjectController.extend({ }, addMembers: function() { - // TODO: should clear the input if (Em.isEmpty(this.get("usernames"))) { return; } this.get("model").addMembers(this.get("usernames")); + // clear the user selector + this.set("usernames", null); }, save: function() { var self = this, - group = this.get('model'); + group = this.get('model'), + groupsController = this.get("controllers.adminGroupsType"); - self.set('disableSave', true); + this.set('disableSave', true); var promise; - if (group.get('id')) { + if (group.get("id")) { promise = group.save(); } else { promise = group.create().then(function() { - var groupsController = self.get('controllers.adminGroups'); groupsController.addObject(group); }); } promise.then(function() { - self.send('showGroup', group); + self.transitionToRoute("adminGroup", group); }, function(e) { var message = $.parseJSON(e.responseText).errors; bootbox.alert(message); @@ -91,12 +91,13 @@ export default Em.ObjectController.extend({ destroy: function() { var group = this.get('model'), - groupsController = this.get('controllers.adminGroups'), + groupsController = this.get('controllers.adminGroupsType'), self = this; - bootbox.confirm(I18n.t("admin.groups.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { - if (result) { - self.set('disableSave', true); + this.set('disableSave', true); + + bootbox.confirm(I18n.t("admin.groups.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(confirmed) { + if (confirmed) { group.destroy().then(function() { groupsController.get('model').removeObject(group); self.transitionToRoute('adminGroups.index'); diff --git a/app/assets/javascripts/admin/controllers/admin-groups-type.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups-type.js.es6 new file mode 100644 index 0000000000..2d75d1911b --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-groups-type.js.es6 @@ -0,0 +1,16 @@ +export default Ember.ArrayController.extend({ + sortProperties: ['name'], + refreshingAutoGroups: false, + + actions: { + refreshAutoGroups: function(){ + var self = this; + this.set('refreshingAutoGroups', true); + Discourse.ajax('/admin/groups/refresh_automatic_groups', {type: 'POST'}).then(function() { + self.transitionToRoute("adminGroupsType", "automatic").then(function() { + self.set('refreshingAutoGroups', false); + }); + }); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-groups.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups.js.es6 deleted file mode 100644 index 03de7cfe98..0000000000 --- a/app/assets/javascripts/admin/controllers/admin-groups.js.es6 +++ /dev/null @@ -1,24 +0,0 @@ -export default Ember.ArrayController.extend({ - sortProperties: ['name'], - - refreshingAutoGroups: false, - - actions: { - refreshAutoGroups: function(){ - var self = this, - groups = this.get('model'); - - self.set('refreshingAutoGroups', true); - this.transitionToRoute('adminGroups.index').then(function() { - Discourse.ajax('/admin/groups/refresh_automatic_groups', {type: 'POST'}).then(function() { - return Discourse.Group.findAll().then(function(newGroups) { - groups.clear(); - groups.addObjects(newGroups); - }).finally(function() { - self.set('refreshingAutoGroups', false); - }); - }); - }); - } - } -}); diff --git a/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 index 57cf7e0270..f4b9ba2a79 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 @@ -17,15 +17,18 @@ export default Ember.ObjectController.extend(BufferedContent, { if (this.get('required')) { ret.push(I18n.t('admin.user_fields.required.enabled')); } + if (this.get('show_on_profile')) { + ret.push(I18n.t('admin.user_fields.show_on_profile.enabled')); + } return ret.join(', '); - }.property('editable', 'required'), + }.property('editable', 'required', 'show_on_profile'), actions: { save: function() { var self = this; - var attrs = this.get('buffered').getProperties('name', 'description', 'field_type', 'editable', 'required'); + var attrs = this.get('buffered').getProperties('name', 'description', 'field_type', 'editable', 'required', 'show_on_profile'); this.get('model').save(attrs).then(function(res) { self.set('model.id', res.user_field.id); diff --git a/app/assets/javascripts/admin/routes/admin_group_route.js b/app/assets/javascripts/admin/routes/admin-group.js.es6 similarity index 60% rename from app/assets/javascripts/admin/routes/admin_group_route.js rename to app/assets/javascripts/admin/routes/admin-group.js.es6 index 89388e1c32..f397297767 100644 --- a/app/assets/javascripts/admin/routes/admin_group_route.js +++ b/app/assets/javascripts/admin/routes/admin-group.js.es6 @@ -1,17 +1,20 @@ -Discourse.AdminGroupRoute = Discourse.Route.extend({ +export default Discourse.Route.extend({ model: function(params) { - var groups = this.modelFor('adminGroups'), + var groups = this.modelFor('adminGroupsType'), group = groups.findProperty('name', params.name); if (!group) { return this.transitionTo('adminGroups.index'); } + return group; }, setupController: function(controller, model) { controller.set("model", model); + // clear the user selector + controller.set("usernames", null); + // load the members of the group model.findMembers(); } }); - diff --git a/app/assets/javascripts/admin/routes/admin-groups-index.js.es6 b/app/assets/javascripts/admin/routes/admin-groups-index.js.es6 new file mode 100644 index 0000000000..33c42cbb0f --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-groups-index.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + redirect: function() { + this.transitionTo("adminGroupsType", "custom"); + } +}) diff --git a/app/assets/javascripts/admin/routes/admin-groups-type.js.es6 b/app/assets/javascripts/admin/routes/admin-groups-type.js.es6 new file mode 100644 index 0000000000..5c04e25064 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-groups-type.js.es6 @@ -0,0 +1,17 @@ +export default Discourse.Route.extend({ + model: function(params) { + return Discourse.Group.findAll().then(function(groups) { + return groups.filterBy("type", params.type); + }); + }, + + actions: { + newGroup: function() { + var self = this; + this.transitionTo("adminGroupsType", "custom").then(function() { + var group = Discourse.Group.create({ automatic: false, visible: true }); + self.transitionTo("adminGroup", group); + }) + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 8d868d7115..b71d9ce99c 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,5 +1,7 @@ -export default function() { - this.resource('admin', function() { +export default { + resource: 'admin', + + map: function() { this.route('dashboard', { path: '/' }); this.resource('adminSiteSettings', { path: '/site_settings' }, function() { this.resource('adminSiteSettingsCategory', { path: 'category/:category_id'} ); @@ -40,8 +42,10 @@ export default function() { this.route('screenedUrls', { path: '/screened_urls' }); }); - this.resource('adminGroups', { path: '/groups'}, function() { - this.resource('adminGroup', { path: '/:name' }); + this.resource('adminGroups', { path: '/groups' }, function() { + this.resource('adminGroupsType', { path: '/:type' }, function() { + this.resource('adminGroup', { path: '/:name' }); + }); }); this.resource('adminUsers', { path: '/users' }, function() { @@ -51,13 +55,12 @@ export default function() { }); this.resource('adminUsersList', { path: '/list' }, function() { - this.route('show', {path: '/:filter'}); + this.route('show', { path: '/:filter' }); }); }); this.resource('adminBadges', { path: '/badges' }, function() { this.route('show', { path: '/:badge_id' }); }); - - }); -} + } +}; diff --git a/app/assets/javascripts/admin/routes/admin_groups_route.js b/app/assets/javascripts/admin/routes/admin_groups_route.js deleted file mode 100644 index e66299a50f..0000000000 --- a/app/assets/javascripts/admin/routes/admin_groups_route.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - Handles routes for admin groups - - @class AdminGroupsRoute - @extends Discourse.Route - @namespace Discourse - @module Discourse -**/ -Discourse.AdminGroupsRoute = Discourse.Route.extend({ - model: function() { - return Discourse.Group.findAll(); - }, - - actions: { - showGroup: function(g) { - // This hack is needed because the autocomplete plugin does not - // refresh properly when the underlying data changes. TODO should - // be to update the plugin so it works properly and remove this hack. - var self = this; - this.transitionTo('adminGroups.index').then(function() { - self.transitionTo('adminGroup', g); - }); - }, - - newGroup: function(){ - var group = Discourse.Group.create({ visible: true }); - this.send('showGroup', group); - } - } -}); - diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index f59f25c4c8..6a903c922b 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -13,7 +13,7 @@
  • {{#link-to 'adminBadges.index'}}{{i18n 'admin.badges.title'}}{{/link-to}}
  • {{/if}} {{#if currentUser.admin}} -
  • {{#link-to 'adminGroups.index'}}{{i18n 'admin.groups.title'}}{{/link-to}}
  • +
  • {{#link-to 'adminGroups'}}{{i18n 'admin.groups.title'}}{{/link-to}}
  • {{/if}}
  • {{#link-to 'adminEmail'}}{{i18n 'admin.email.title'}}{{/link-to}}
  • {{#link-to 'adminFlags'}}{{i18n 'admin.flags.title'}}{{/link-to}}
  • @@ -23,6 +23,7 @@
  • {{#link-to 'admin.api'}}{{i18n 'admin.api.title'}}{{/link-to}}
  • {{#link-to 'admin.backups'}}{{i18n 'admin.backups.title'}}{{/link-to}}
  • {{/if}} + {{plugin-outlet "admin-menu" tagName="li"}}
    diff --git a/app/assets/javascripts/admin/templates/dashboard.hbs b/app/assets/javascripts/admin/templates/dashboard.hbs index b788bb08f0..a2e8479f92 100644 --- a/app/assets/javascripts/admin/templates/dashboard.hbs +++ b/app/assets/javascripts/admin/templates/dashboard.hbs @@ -118,7 +118,7 @@

    {{i18n 'admin.dashboard.last_checked'}}: {{problemsTimestamp}} - + {{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}

    @@ -130,7 +130,7 @@

    {{i18n 'admin.dashboard.no_problems'}} - + {{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}

    diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 03d9d697e6..2800ac2b9c 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -43,6 +43,17 @@ {{combo-box name="alias" valueAttribute="value" value=alias_level content=aliasLevelOptions}} + {{#unless automatic}} +
    + + {{list-setting name="automatic_membership" settingValue=emailDomains}} + +
    + {{/unless}} +
    {{#unless automatic}} diff --git a/app/assets/javascripts/admin/templates/groups.hbs b/app/assets/javascripts/admin/templates/groups.hbs index 061bf715de..751e46323f 100644 --- a/app/assets/javascripts/admin/templates/groups.hbs +++ b/app/assets/javascripts/admin/templates/groups.hbs @@ -1,20 +1,11 @@ -
    -
    -

    {{i18n 'admin.groups.edit'}}

    -
      - {{#each group in arrangedContent}} -
    • - {{group.name}} {{group.userCountDisplay}} -
    • - {{/each}} +
      +
      + -
      - - -
      -
      - -
      - {{outlet}}
      +
      + {{outlet}} +
      diff --git a/app/assets/javascripts/admin/templates/groups_index.hbs b/app/assets/javascripts/admin/templates/groups_index.hbs deleted file mode 100644 index f8a3f440e6..0000000000 --- a/app/assets/javascripts/admin/templates/groups_index.hbs +++ /dev/null @@ -1 +0,0 @@ -{{i18n 'admin.groups.about'}} diff --git a/app/assets/javascripts/admin/templates/groups_type.hbs b/app/assets/javascripts/admin/templates/groups_type.hbs new file mode 100644 index 0000000000..0aec861a66 --- /dev/null +++ b/app/assets/javascripts/admin/templates/groups_type.hbs @@ -0,0 +1,20 @@ +
      +
      +

      {{i18n 'admin.groups.edit'}}

      +
        + {{#each group in controller}} +
      • + {{#link-to "adminGroup" group.type group.name}}{{group.name}} {{group.userCountDisplay}}{{/link-to}} +
      • + {{/each}} +
      +
      + {{d-button action="newGroup" icon="plus" label="admin.groups.new"}} + {{d-button action="refreshAutoGroups" icon="refresh" label="admin.groups.refresh" disabled=refreshingAutoGroups}} +
      +
      + +
      + {{outlet}} +
      +
      diff --git a/app/assets/javascripts/admin/templates/reports.hbs b/app/assets/javascripts/admin/templates/reports.hbs index 98e7097408..e4fdc87cbf 100644 --- a/app/assets/javascripts/admin/templates/reports.hbs +++ b/app/assets/javascripts/admin/templates/reports.hbs @@ -3,7 +3,7 @@
      {{i18n 'admin.dashboard.reports.start_date'}} {{input type="date" value=startDate}} {{i18n 'admin.dashboard.reports.end_date'}} {{input type="date" value=endDate}} - + {{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}}
      diff --git a/app/assets/javascripts/admin/templates/site_settings/setting_enum.hbs b/app/assets/javascripts/admin/templates/site_settings/setting_enum.hbs index 9a9ff20fce..b0f06e5b14 100644 --- a/app/assets/javascripts/admin/templates/site_settings/setting_enum.hbs +++ b/app/assets/javascripts/admin/templates/site_settings/setting_enum.hbs @@ -3,6 +3,7 @@
      {{combo-box valueAttribute="value" content=validValues value=value none=allowsNone}} + {{view.preview}}
      {{unbound description}}
      {{#if dirty}} diff --git a/app/assets/javascripts/admin/templates/user-fields.hbs b/app/assets/javascripts/admin/templates/user-fields.hbs index 32505f98c9..2412dfd1ad 100644 --- a/app/assets/javascripts/admin/templates/user-fields.hbs +++ b/app/assets/javascripts/admin/templates/user-fields.hbs @@ -32,6 +32,11 @@ {{input type="checkbox" checked=f.buffered.required}} {{i18n 'admin.user_fields.required.title'}}
    +
    + +
    {{else}}
    diff --git a/app/assets/javascripts/admin/views/site_setting_view.js b/app/assets/javascripts/admin/views/site_setting_view.js index 0a3983bd24..4f5719d17b 100644 --- a/app/assets/javascripts/admin/views/site_setting_view.js +++ b/app/assets/javascripts/admin/views/site_setting_view.js @@ -9,6 +9,16 @@ Discourse.SiteSettingView = Discourse.View.extend(Discourse.ScrollTop, { classNameBindings: [':row', ':setting', 'content.overridden'], + preview: function() { + var preview = this.get('content.preview'); + if(preview){ + return new Handlebars.SafeString("
    " + + preview.replace("{{value}}",this.get('content.value')) + + "
    " + ); + } + }.property('content.value'), + templateName: function() { // If we're editing a boolean, show a checkbox if (this.get('content.type') === 'bool') return 'admin/templates/site_settings/setting_bool'; diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index 27b12c53ab..feb375d90c 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -24,6 +24,12 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, { return u + url; }, + getURLWithCDN: function(url) { + url = this.getURL(url); + if (Discourse.CDN) { url = Discourse.CDN + url; } + return url; + }, + Resolver: DiscourseResolver, _titleChanged: function() { diff --git a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 index f711802a40..b67da9c65f 100644 --- a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 @@ -14,9 +14,6 @@ export default Em.Component.extend(UploadMixin, { uploadDone: function(data) { var self = this; - // indicates the users is using an uploaded avatar - this.set("custom_avatar_upload_id", data.result.upload_id); - // display a warning whenever the image is not a square this.set("imageIsNotASquare", data.result.width !== data.result.height); @@ -26,6 +23,11 @@ export default Em.Component.extend(UploadMixin, { // this will also capture the first frame of animated avatars when they're not allowed Discourse.Utilities.cropAvatar(data.result.url, data.files[0].type).then(function(avatarTemplate) { self.set("uploadedAvatarTemplate", avatarTemplate); + + // indicates the users is using an uploaded avatar (must happen after cropping, otherwise + // we will attempt to load an invalid avatar and cache a redirect to old one, uploadedAvatarTemplate + // trumps over custom avatar upload id) + self.set("custom_avatar_upload_id", data.result.upload_id); }); // the upload is now done diff --git a/app/assets/javascripts/discourse/components/category-drop.js.es6 b/app/assets/javascripts/discourse/components/category-drop.js.es6 index b05b9dcd77..88217ef1f6 100644 --- a/app/assets/javascripts/discourse/components/category-drop.js.es6 +++ b/app/assets/javascripts/discourse/components/category-drop.js.es6 @@ -1,13 +1,12 @@ -/** - Renders a drop down for selecting a category +var get = Ember.get; - @class CategoryDropComponent - @extends Ember.Component - @namespace Discourse - @module Discourse -**/ export default Ember.Component.extend({ - classNameBindings: ['category::no-category', 'categories:has-drop'], + classNameBindings: ['category::no-category', 'categories:has-drop','categoryStyle'], + + categoryStyle: function(){ + return Discourse.SiteSettings.category_style; + }.property(), + tagName: 'li', iconClass: function() { @@ -44,11 +43,20 @@ export default Ember.Component.extend({ badgeStyle: function() { var category = this.get('category'); + if (category) { - return Discourse.HTML.categoryStyle(category); - } else { - return "background-color: #eee; color: #333"; + var color = get(category, 'color'), + textColor = get(category, 'text_color'); + + if (color || textColor) { + var style = ""; + if (color) { style += "background-color: #" + color + "; border-color: #" + color + ";"; } + if (textColor) { style += "color: #" + textColor + "; "; } + return style; + } } + + return "background-color: #eee; color: #333"; }.property('category'), clickEventName: function() { @@ -81,24 +89,33 @@ export default Ember.Component.extend({ self.close(); }); - $('html').on(this.get('clickEventName'), function(e) { - var $target = $(e.target), - closest = $target.closest($dropdown); + Em.run.next(function(){ + self.$('.cat a').add('html').on(self.get('clickEventName'), function(e) { + var $target = $(e.target), + closest = $target.closest($dropdown); - return ($(e.currentTarget).hasClass('badge-category') || (closest.length && closest[0] === $dropdown)) ? true : self.close(); + if ($(e.currentTarget).hasClass('badge-wrapper')){ + self.close(); + } + + return ($(e.currentTarget).hasClass('badge-category') || (closest.length && closest[0] === $dropdown)) ? true : self.close(); + }); }); } }, - close: function() { + removeEvents: function(){ $('html').off(this.get('clickEventName')); this.$('a[data-drop-close]').off('click.category-drop'); + }, + + close: function() { + this.removeEvents(); this.set('expanded', false); }, willDestroyElement: function() { - $('html').off(this.get('clickEventName')); - this.$('a[data-drop-close]').off('click.category-drop'); + this.removeEvents(); } }); diff --git a/app/assets/javascripts/discourse/components/category-group.js.es6 b/app/assets/javascripts/discourse/components/category-group.js.es6 index 32434e74a9..14caa32bfe 100644 --- a/app/assets/javascripts/discourse/components/category-group.js.es6 +++ b/app/assets/javascripts/discourse/components/category-group.js.es6 @@ -1,3 +1,5 @@ +import { categoryBadgeHTML } from 'discourse/helpers/category-link'; + export default Ember.Component.extend({ _initializeAutocomplete: function(){ @@ -25,7 +27,7 @@ export default Ember.Component.extend({ }, template: template, transformComplete: function(category) { - return Discourse.HTML.categoryBadge(category, {allowUncategorized: true}); + return categoryBadgeHTML(category, {allowUncategorized: true}); } }); }.on('didInsertElement') diff --git a/app/assets/javascripts/discourse/components/category-title-link.js.es6 b/app/assets/javascripts/discourse/components/category-title-link.js.es6 index 2f33b7e5e9..e00efa6836 100644 --- a/app/assets/javascripts/discourse/components/category-title-link.js.es6 +++ b/app/assets/javascripts/discourse/components/category-title-link.js.es6 @@ -7,7 +7,7 @@ export default Em.Component.extend({ categoryUrl = Discourse.getURL('/c/') + Discourse.Category.slugFor(category), categoryName = Handlebars.Utils.escapeExpression(category.get('name')); - if (category.get('read_restricted')) { buffer.push(""); } + if (category.get('read_restricted')) { buffer.push(""); } buffer.push(""); buffer.push("" + categoryName + ""); diff --git a/app/assets/javascripts/discourse/components/d-button.js.es6 b/app/assets/javascripts/discourse/components/d-button.js.es6 new file mode 100644 index 0000000000..29bd2a5f1f --- /dev/null +++ b/app/assets/javascripts/discourse/components/d-button.js.es6 @@ -0,0 +1,31 @@ +import { iconHTML } from 'discourse/helpers/fa-icon'; + +export default Ember.Component.extend({ + tagName: 'button', + classNameBindings: [':btn'], + attributeBindings: ['disabled', 'translatedTitle:title'], + + translatedTitle: function() { + var label = this.get('label'); + if (label) { + return I18n.t(this.get('label')); + } + }.property('label'), + + render: function(buffer) { + var title = this.get('translatedTitle'), + icon = this.get('icon'); + + if (title || icon) { + if (icon) { buffer.push(iconHTML(icon) + ' '); } + if (title) { buffer.push(title); } + } else { + // If no label or icon is present, yield + return this._super(); + } + }, + + click: function() { + this.sendAction("action", this.get("actionParam")); + } +}); diff --git a/app/assets/javascripts/discourse/components/disabled-icon.js.es6 b/app/assets/javascripts/discourse/components/disabled-icon.js.es6 new file mode 100644 index 0000000000..abcd8e7279 --- /dev/null +++ b/app/assets/javascripts/discourse/components/disabled-icon.js.es6 @@ -0,0 +1,4 @@ +export default Ember.Component.extend({ + tagName: 'span', + classNameBindings: [':fa-stack'], +}); diff --git a/app/assets/javascripts/discourse/components/global-notice.js.es6 b/app/assets/javascripts/discourse/components/global-notice.js.es6 index 0f6f60f674..bde5b4aaa5 100644 --- a/app/assets/javascripts/discourse/components/global-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/global-notice.js.es6 @@ -10,6 +10,10 @@ export default Ember.Component.extend(StringBuffer, { notices.push(I18n.t("read_only_mode.enabled")); } + if (this.siteSettings.disable_emails) { + notices.push(I18n.t("emails_are_disabled")); + } + if (Discourse.User.currentProp('admin') && this.siteSettings.show_create_topics_notice) { var topic_count = 0, post_count = 0; diff --git a/app/assets/javascripts/discourse/components/topic-list.js.es6 b/app/assets/javascripts/discourse/components/topic-list.js.es6 index effcb2c817..865f5fb9a1 100644 --- a/app/assets/javascripts/discourse/components/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list.js.es6 @@ -18,6 +18,10 @@ export default Ember.Component.extend({ return !!this.get('changeSort'); }.property(), + skipHeader: function() { + return Discourse.Mobile.mobileView; + }.property(), + showLikes: function(){ return this.get('order') === "likes"; }.property('order'), diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index 15d50faeda..f008c599b3 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -16,18 +16,15 @@ export default TextField.extend({ return selected; } - var template = this.container.lookup('template:user-selector-autocomplete.raw'); - $(this.get('element')).val(this.get('usernames')).autocomplete({ - template: template, - + this.$().val(this.get('usernames')).autocomplete({ + template: this.container.lookup('template:user-selector-autocomplete.raw'), disabled: this.get('disabled'), single: this.get('single'), allowAny: this.get('allowAny'), dataSource: function(term) { - term = term.replace(/[^a-zA-Z0-9_]/, ''); return userSearch({ - term: term, + term: term.replace(/[^a-zA-Z0-9_]/, ''), topicId: self.get('topicId'), exclude: excludedUsernames(), includeGroups: includeGroups @@ -58,6 +55,15 @@ export default TextField.extend({ } }); - }.on('didInsertElement') + }.on('didInsertElement'), + + // THIS IS A HUGE HACK TO SUPPORT CLEARING THE INPUT + _clearInput: function() { + if (arguments.length > 1) { + if (Em.isEmpty(this.get("usernames"))) { + this.$().parent().find("a").click(); + } + } + }.observes("usernames") }); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index aaa43a740c..b1bf16cf29 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -39,7 +39,34 @@ export default DiscourseController.extend({ // Import a quote from the post importQuote: function() { - this.get('model').importQuote(); + var postStream = this.get('topic.postStream'), + postId = this.get('model.post.id'); + + // If there is no current post, use the first post id from the stream + if (!postId && postStream) { + postId = postStream.get('firstPostId'); + } + + // If we're editing a post, fetch the reply when importing a quote + if (this.get('model.editingPost')) { + var replyToPostNumber = this.get('model.post.reply_to_post_number'); + if (replyToPostNumber) { + var replyPost = postStream.get('posts').findBy('post_number', replyToPostNumber); + if (replyPost) { + postId = replyPost.get('id'); + } + } + } + + if (postId) { + this.set('model.loading', true); + var composer = this; + return Discourse.Post.load(postId).then(function(post) { + var quote = Discourse.Quote.build(post, post.get("raw")) + composer.appendBlockAtCursor(quote); + composer.set('model.loading', false); + }); + } }, cancel: function() { @@ -212,7 +239,9 @@ export default DiscourseController.extend({ } if ((!composer.get('replyingToTopic')) || (!Discourse.User.currentProp('disable_jump_reply'))) { - Discourse.URL.routeTo(opts.post.get('url')); + if (opts.post) { + Discourse.URL.routeTo(opts.post.get('url')); + } } }, function(error) { composer.set('disableDrafts', false); diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index 0fff6219a6..1e23c9154a 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -8,7 +8,6 @@ export default DiscoveryController.extend({ actions: { refresh: function() { - var self = this; // Don't refresh if we're still loading if (this.get('controllers.discovery.loading')) { return; } @@ -17,7 +16,17 @@ export default DiscoveryController.extend({ // router and ember throws an error due to missing `handlerInfos`. // Lesson learned: Don't call `loading` yourself. this.set('controllers.discovery.loading', true); - Discourse.CategoryList.list('categories').then(function(list) { + + var parentCategory = this.get('model.parentCategory'); + var promise; + if (parentCategory) { + promise = Discourse.CategoryList.listForParent(parentCategory); + } else { + promise = Discourse.CategoryList.list(); + } + + var self = this; + promise.then(function(list) { self.set('model', list); self.send('loadingComplete'); }); diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 544684c195..9d3a7f2928 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -50,6 +50,8 @@ var controllerOpts = { // Lesson learned: Don't call `loading` yourself. this.set('controllers.discovery.loading', true); Discourse.TopicList.find(filter).then(function(list) { + Discourse.TopicList.hideUniformCategory(list, self.get('category')); + self.setProperties({ model: list, selected: [] }); var tracking = Discourse.TopicTrackingState.current(); diff --git a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 index 443d3bcb7c..5928e97ea0 100644 --- a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 @@ -1,5 +1,6 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import ObjectController from 'discourse/controllers/object'; +import { categoryBadgeHTML } from 'discourse/helpers/category-link'; // Modal for editing / creating a category export default ObjectController.extend(ModalFunctionality, { @@ -69,7 +70,7 @@ export default ObjectController.extend(ModalFunctionality, { parent_category_id: parseInt(this.get('parent_category_id'),10), read_restricted: this.get('model.read_restricted') }); - return Discourse.HTML.categoryBadge(c, {showParent: true, link: false}); + return categoryBadgeHTML(c, {link: false}); }.property('parent_category_id', 'categoryName', 'color', 'text_color'), // background colors are available as a pipe-separated string diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 2a00dc18d5..e3603089e9 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -1,5 +1,6 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import ObjectController from 'discourse/controllers/object'; +import { categoryBadgeHTML } from 'discourse/helpers/category-link'; // This controller handles displaying of history export default ObjectController.extend(ModalFunctionality, { @@ -22,14 +23,14 @@ export default ObjectController.extend(ModalFunctionality, { hide: function(postId, postVersion) { var self = this; - Discourse.Post.hideRevision(postId, postVersion).then(function (result) { + Discourse.Post.hideRevision(postId, postVersion).then(function () { self.refresh(postId, postVersion); }); }, show: function(postId, postVersion) { var self = this; - Discourse.Post.showRevision(postId, postVersion).then(function (result) { + Discourse.Post.showRevision(postId, postVersion).then(function () { self.refresh(postId, postVersion); }); }, @@ -65,47 +66,38 @@ export default ObjectController.extend(ModalFunctionality, { displayingSideBySideMarkdown: Em.computed.equal("viewMode", "side_by_side_markdown"), previousCategory: function() { - var changes = this.get("category_changes"); + var changes = this.get("category_id_changes"); if (changes) { var category = Discourse.Category.findById(changes["previous"]); - return Discourse.HTML.categoryBadge(category, { allowUncategorized: true }); + return categoryBadgeHTML(category, { allowUncategorized: true }); } - }.property("category_changes"), + }.property("category_id_changes"), currentCategory: function() { - var changes = this.get("category_changes"); + var changes = this.get("category_id_changes"); if (changes) { var category = Discourse.Category.findById(changes["current"]); - return Discourse.HTML.categoryBadge(category, { allowUncategorized: true }); + return categoryBadgeHTML(category, { allowUncategorized: true }); } - }.property("category_changes"), + }.property("category_id_changes"), - wiki_diff: function() { - var changes = this.get("wiki_changes") - if (changes) { - return changes["current"] ? - '' : - ''; - } - }.property("wiki_changes"), + wikiDisabled: function() { + var changes = this.get("wiki_changes"); + return changes && !changes['current']; + }.property('wiki_changes'), - post_type_diff: function () { - var moderator = Discourse.Site.currentProp('post_types.moderator_action'); + postTypeDisabled: function () { var changes = this.get("post_type_changes"); - if (changes) { - return changes["current"] == moderator ? - '' : - ''; - } + return (changes && changes['current'] !== this.site.get('post_types.moderator_action')); }.property("post_type_changes"), - title_diff: function() { + titleDiff: function() { var viewMode = this.get("viewMode"); if (viewMode === "side_by_side_markdown") { viewMode = "side_by_side"; } return this.get("title_changes." + viewMode); }.property("viewMode", "title_changes"), - body_diff: function() { + bodyDiff: function() { return this.get("body_changes." + this.get("viewMode")); }.property("viewMode", "body_changes"), diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index f405e6ee1b..8e6c722c6a 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -160,6 +160,12 @@ export default DiscourseController.extend(ModalFunctionality, { this.set('authenticate', null); return; } + if (options.suspended) { + this.send('showLogin'); + this.flash(options.suspended_message, 'error'); + this.set('authenticate', null); + return; + } // Reload the page if we're authenticated if (options.authenticated) { if (window.location.pathname === Discourse.getURL('/login')) { diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 3f75db244d..4131a03c54 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -35,15 +35,19 @@ export default ObjectController.extend(CanCheckEmails, { canEditName: Discourse.computed.setting('enable_names'), canSelectTitle: function() { - return Discourse.SiteSettings.enable_badges && this.get('model.has_title_badges'); + return this.siteSettings.enable_badges && this.get('model.has_title_badges'); }.property('model.badge_count'), canChangePassword: function() { - return !Discourse.SiteSettings.enable_sso && Discourse.SiteSettings.enable_local_logins; + return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins; + }.property(), + + canReceiveDigest: function() { + return !this.siteSettings.disable_digest_emails; }.property(), availableLocales: function() { - return Discourse.SiteSettings.available_locales.split('|').map( function(s) { + return this.siteSettings.available_locales.split('|').map( function(s) { return {name: s, value: s}; }); }.property(), @@ -166,5 +170,3 @@ export default ObjectController.extend(CanCheckEmails, { } }); - - diff --git a/app/assets/javascripts/discourse/controllers/static.js.es6 b/app/assets/javascripts/discourse/controllers/static.js.es6 index 0dd1f25b60..f2e18692d0 100644 --- a/app/assets/javascripts/discourse/controllers/static.js.es6 +++ b/app/assets/javascripts/discourse/controllers/static.js.es6 @@ -3,7 +3,9 @@ export default Em.ObjectController.extend({ actions: { markFaqRead: function() { - Discourse.ajax("/users/read-faq", { method: "POST" }); + if (Discourse.User.current()) { + Discourse.ajax("/users/read-faq", { method: "POST" }); + } } } }); diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 247ebfffd4..b066362775 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -42,6 +42,21 @@ export default ObjectController.extend(CanCheckEmails, { return this.get('can_be_deleted') && this.get('can_delete_all_posts'); }.property('can_be_deleted', 'can_delete_all_posts'), + publicUserFields: function() { + var siteUserFields = this.site.get('user_fields'); + if (!Ember.isEmpty(siteUserFields)) { + var userFields = this.get('user_fields'); + return siteUserFields.filterProperty('show_on_profile', true).sortBy('id').map(function(uf) { + var val = userFields ? userFields[uf.get('id').toString()] : null; + if (Ember.isEmpty(val)) { + return null; + } else { + return Ember.Object.create({value: val, field: uf}); + } + }).compact(); + } + }.property('user_fields.@each.value'), + privateMessagesActive: Em.computed.equal('pmView', 'index'), privateMessagesMineActive: Em.computed.equal('pmView', 'mine'), privateMessagesUnreadActive: Em.computed.equal('pmView', 'unread'), diff --git a/app/assets/javascripts/discourse/dialects/html.js b/app/assets/javascripts/discourse/dialects/html_dialect.js similarity index 100% rename from app/assets/javascripts/discourse/dialects/html.js rename to app/assets/javascripts/discourse/dialects/html_dialect.js diff --git a/app/assets/javascripts/discourse/dialects/nested_link_dialects.js b/app/assets/javascripts/discourse/dialects/nested_link_dialect.js similarity index 100% rename from app/assets/javascripts/discourse/dialects/nested_link_dialects.js rename to app/assets/javascripts/discourse/dialects/nested_link_dialect.js diff --git a/app/assets/javascripts/discourse/dialects/quote_dialect.js b/app/assets/javascripts/discourse/dialects/quote_dialect.js index b0b8f5bbb8..3c50598cfc 100644 --- a/app/assets/javascripts/discourse/dialects/quote_dialect.js +++ b/app/assets/javascripts/discourse/dialects/quote_dialect.js @@ -1,4 +1,5 @@ var esc = Handlebars.Utils.escapeExpression; + Discourse.BBCode.register('quote', {noWrap: true, singlePara: true}, function(contents, bbParams, options) { var params = {'class': 'quote'}, username = null; diff --git a/app/assets/javascripts/discourse/helpers/category-link.js.es6 b/app/assets/javascripts/discourse/helpers/category-link.js.es6 index 412a64df99..9f7d3f4ed4 100644 --- a/app/assets/javascripts/discourse/helpers/category-link.js.es6 +++ b/app/assets/javascripts/discourse/helpers/category-link.js.es6 @@ -1,4 +1,70 @@ import registerUnbound from 'discourse/helpers/register-unbound'; +import { iconHTML } from 'discourse/helpers/fa-icon'; + +var get = Em.get, + escapeExpression = Handlebars.Utils.escapeExpression; + +function categoryStripe(color, classes) { + var style = color ? "style='background-color: #" + color + ";'" : ""; + return ""; +} + +export function categoryBadgeHTML(category, opts) { + opts = opts || {}; + + if ((!category) || + (!opts.allowUncategorized && + Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id") && + Discourse.SiteSettings.suppress_uncategorized_badge + ) + ) return ""; + + var description = get(category, 'description_text'), + restricted = get(category, 'read_restricted'), + url = Discourse.getURL("/c/") + Discourse.Category.slugFor(category), + href = (opts.link === false ? '' : url), + tagName = (opts.link === false || opts.link === "false" ? 'span' : 'a'), + extraClasses = (opts.extraClasses ? (' ' + opts.extraClasses) : ''), + color = get(category, 'color'), + html = "", + parentCat = null; + + if (!opts.hideParent) { + parentCat = Discourse.Category.findById(get(category, 'parent_category_id')); + } + + if (parentCat && parentCat !== category) { + html += categoryStripe(get(parentCat,'color'), "badge-category-parent-bg"); + } + + html += categoryStripe(color, "badge-category-bg"); + + var classNames = "badge-category clear-badge"; + if (restricted) { classNames += " restricted"; } + + var textColor = "#" + get(category, 'text_color'); + + html += ""; + + var name = escapeExpression(get(category, 'name')); + if (restricted) { + html += iconHTML('lock') + " " + name; + } else { + html += name; + } + html += ""; + + if(href){ + href = " href='" + href + "' "; + } + + extraClasses = Discourse.SiteSettings.category_style ? Discourse.SiteSettings.category_style + extraClasses : extraClasses; + + return "<" + tagName + " class='badge-wrapper " + extraClasses + "' " + href + ">" + html + ""; +} export function categoryLinkHTML(category, options) { var categoryOptions = {}; @@ -9,12 +75,11 @@ export function categoryLinkHTML(category, options) { if (options) { if (options.allowUncategorized) { categoryOptions.allowUncategorized = true; } - if (options.showParent) { categoryOptions.showParent = true; } - if (options.onlyStripe) { categoryOptions.onlyStripe = true; } if (options.link !== undefined) { categoryOptions.link = options.link; } if (options.extraClasses) { categoryOptions.extraClasses = options.extraClasses; } + if (options.hideParent) { categoryOptions.hideParent = true; } } - return new Handlebars.SafeString(Discourse.HTML.categoryBadge(category, categoryOptions)); + return new Handlebars.SafeString(categoryBadgeHTML(category, categoryOptions)); } registerUnbound('category-link', categoryLinkHTML); diff --git a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 index 25ac7aebce..cce1f3aa8b 100644 --- a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 +++ b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 @@ -1,12 +1,22 @@ -Handlebars.registerHelper('fa-icon', function(icon, options) { - var labelKey; - if (options.hash) { labelKey = options.hash.label; } +import registerUnbound from 'discourse/helpers/register-unbound'; - var html = ""; + if (label) { + html += "" + I18n.t(label) + ""; } - return new Handlebars.SafeString(html); + return html; +} + + +registerUnbound('fa-icon', function(icon, params) { + return new Handlebars.SafeString(iconHTML(icon, params.label, params.modifier)); }); diff --git a/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 index 120eb208b8..42802f8bce 100644 --- a/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 +++ b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 @@ -88,20 +88,21 @@ export default function(connectionName, options) { if (!_connectorCache) { buildConnectorCache(); } if (_connectorCache[connectionName]) { - var view; + var viewClass; var childViews = _connectorCache[connectionName]; // If there is more than one view, create a container. Otherwise // just shove it in. if (childViews.length > 1) { - view = Ember.ContainerView.extend({ + viewClass = Ember.ContainerView.extend({ childViews: childViews }); } else { - view = childViews[0]; + viewClass = childViews[0]; } + delete options.fn; // we don't need the default template since we have a connector - return Ember.Handlebars.helpers.view.call(this, view, options); + return Ember.Handlebars.helpers.view.call(this, viewClass, options); } else if (options.fn) { // If a block is passed, render its content. return Ember.Handlebars.helpers.view.call(this, diff --git a/app/assets/javascripts/discourse/initializers/ie9-hax.js.es6 b/app/assets/javascripts/discourse/initializers/ie9-hax.js.es6 new file mode 100644 index 0000000000..4c124aac05 --- /dev/null +++ b/app/assets/javascripts/discourse/initializers/ie9-hax.js.es6 @@ -0,0 +1,10 @@ +export default { + name: 'ie9-hacks', + initialize: function() { + if (!window) { return; } + + // IE9 does not support a console object unless the developer tools are open + if (!window.console) { window.console = {}; } + if (!window.console.log) { window.console.log = Ember.K; } + } +}; diff --git a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 index 31933358d5..885a3edd14 100644 --- a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 +++ b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 @@ -1,11 +1,12 @@ import Session from 'discourse/models/session'; +import AppEvents from 'discourse/lib/app-events'; export default { name: "inject-objects", initialize: function(container, application) { // Inject appEvents everywhere - var appEvents = Ember.Object.createWithMixins(Ember.Evented); + var appEvents = AppEvents.create(); application.register('app-events:main', appEvents, { instantiate: false }); application.inject('controller', 'appEvents', 'app-events:main'); diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js index 41679bb8d0..8109805b87 100644 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ b/app/assets/javascripts/discourse/lib/Markdown.Editor.js @@ -1676,7 +1676,7 @@ return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical }); link = decodeURIComponent(link); // unencode first, to prevent double encoding - link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); + link = encodeURI(link).replace(/#/g, '%23').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); link = link.replace(/\?.*$/, function (querypart) { return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded }); diff --git a/app/assets/javascripts/discourse/lib/app-events.js.es6 b/app/assets/javascripts/discourse/lib/app-events.js.es6 new file mode 100644 index 0000000000..26135d3f7c --- /dev/null +++ b/app/assets/javascripts/discourse/lib/app-events.js.es6 @@ -0,0 +1,23 @@ +export default Ember.Object.extend(Ember.Evented); + +var id = 1; +function newKey() { + return "_view_app_event_" + (id++); +} + +export function createViewListener(eventName, cb) { + var extension = {}; + extension[newKey()] = function() { + this.appEvents.on(eventName, this, cb); + }.on('didInsertElement'); + + extension[newKey()] = function() { + this.appEvents.off(eventName, this, cb); + }.on('willDestroyElement'); + + return extension; +} + +export function listenForViewEvent(viewClass, eventName, cb) { + viewClass.reopen(createViewListener(eventName, cb)); +} 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 f40b5ed51e..2de2a76f54 100644 --- a/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 +++ b/app/assets/javascripts/discourse/lib/emoji/emoji-toolbar.js.es6 @@ -40,34 +40,89 @@ var closeSelector = function(){ $('body, textarea').off('keydown.emoji'); }; -var ungroupedIcons; +var ungroupedIcons, recentlyUsedIcons; + +var initializeUngroupedIcons = function(){ + ungroupedIcons = []; + + var groupedIcons = {}; + _.each(groups, function(group){ + _.each(group.icons, function(icon){ + groupedIcons[icon] = true; + }); + }); + + var emojis = Discourse.Emoji.list(); + _.each(emojis, function(emoji){ + if(groupedIcons[emoji] !== true){ + ungroupedIcons.push(emoji); + } + }); + + if(ungroupedIcons.length > 0){ + groups.push({name: 'ungrouped', icons: ungroupedIcons}); + } +}; + +try { + if (localStorage && !localStorage.emojiUsage) { localStorage.emojiUsage = "{}"; } +} catch(e){ +/* localStorage can be disabled, or cookies disabled, do not crash script here + * TODO introduce a global wrapper for dealing with local storage + * */ +} + +var trackEmojiUsage = function(title){ + var recent = JSON.parse(localStorage.emojiUsage); + + if (!recent[title]) { recent[title] = { title: title, usage: 0 }; } + recent[title]["usage"]++; + + localStorage.emojiUsage = JSON.stringify(recent); + + // clear the cache + recentlyUsedIcons = null; +}; + +var initializeRecentlyUsedIcons = function(){ + recentlyUsedIcons = []; + + var usage = _.map(JSON.parse(localStorage.emojiUsage)); + usage.sort(function(a,b){ + if(a.usage > b.usage){ + return -1; + } + if(b.usage > a.usage){ + return 1; + } + return a.title.localeCompare(b.title); + }); + + var recent = _.take(usage, PER_ROW); + + if(recent.length > 0){ + _.each(recent, function(emoji){ + recentlyUsedIcons.push(emoji.title); + }); + + var recentGroup = _.find(groups, {name: 'recent'}); + if(!recentGroup){ + recentGroup = {name: 'recent', icons: []}; + groups.push(recentGroup); + } + + recentGroup.icons = recentlyUsedIcons; + } +}; var toolbar = function(selected){ - if(!ungroupedIcons){ - ungroupedIcons = []; - var groupedIcons = {}; - - _.each(groups, function(group){ - _.each(group.icons, function(icon){ - groupedIcons[icon] = true; - }); - }); - - var emojis = Discourse.Emoji.list(); - _.each(emojis,function(emoji){ - if(groupedIcons[emoji] !== true){ - ungroupedIcons.push(emoji); - } - }); - - if(ungroupedIcons.length > 0){ - groups.push({name: 'ungrouped', icons: ungroupedIcons}); - } - } + if (!ungroupedIcons) { initializeUngroupedIcons(); } + if (!recentlyUsedIcons) { initializeRecentlyUsedIcons(); } return _.map(groups, function(g, i){ - var row = {src: Discourse.Emoji.urlFor(g.icons[0]), groupId: i}; - if(i===selected){ + var icon = g.name === "recent" ? "star2" : g.icons[0]; + var row = {src: Discourse.Emoji.urlFor(icon), groupId: i}; + if(i === selected){ row.selected = true; } return row; @@ -80,9 +135,11 @@ var bindEvents = function(page,offset){ var composerController = Discourse.__container__.lookup('controller:composer'); $('.emoji-page a').click(function(){ - composerController.appendTextAtCursor(":" + $(this).attr('title') + ":", {space: true}); - closeSelector(); - return false; + var title = $(this).attr('title'); + trackEmojiUsage(title) + composerController.appendTextAtCursor(":" + title + ":", {space: true}); + closeSelector(); + return false; }).hover(function(){ var title = $(this).attr('title'); var html = " :" + title + ":"; diff --git a/app/assets/javascripts/discourse/lib/emoji/emoji.js.erb b/app/assets/javascripts/discourse/lib/emoji/emoji.js.erb index b15b444cec..786485e496 100644 --- a/app/assets/javascripts/discourse/lib/emoji/emoji.js.erb +++ b/app/assets/javascripts/discourse/lib/emoji/emoji.js.erb @@ -7,6 +7,7 @@ var emoji = <%= Emoji.standard.map(&:name).flatten.inspect %>; var extendedEmoji = {}; Discourse.Dialect.registerEmoji = function(code, url) { + code = code.toLowerCase(); extendedEmoji[code] = url; }; @@ -53,13 +54,13 @@ var search = function(term, options) { Discourse.Emoji.search = search; var emojiHash = {}; -emoji.forEach(function(code){ - emojiHash[code] = true; -}); +emoji.forEach(function(code){ emojiHash[code] = true; }); var urlFor = function(code) { var url, set = Discourse.SiteSettings.emoji_set; + code = code.toLowerCase(); + if(extendedEmoji.hasOwnProperty(code)) { url = extendedEmoji[code]; } @@ -82,10 +83,12 @@ var urlFor = function(code) { Discourse.Emoji.urlFor = urlFor; Discourse.Emoji.exists = function(code){ - return !!(extendedEmoji.hasOwnProperty(code) || emojiHash.hasOwnProperty(code)); + code = code.toLowerCase(); + return !!(extendedEmoji.hasOwnProperty(code) || emojiHash.hasOwnProperty(code)); } function imageFor(code) { + code = code.toLowerCase(); var url = urlFor(code); if (url) { return ['img', { href: url, title: ':' + code + ':', 'class': 'emoji', alt: code }]; @@ -145,7 +148,7 @@ Object.keys(translations).forEach(function (t) { }); function escapeRegExp(s) { - return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + return s.replace(/[-/\\^$*+?.()|[\]{}]/gi, '\\$&'); } var translationColonRegexp = new RegExp(Object.keys(translationsWithColon).map(function (t) { diff --git a/app/assets/javascripts/discourse/lib/html.js b/app/assets/javascripts/discourse/lib/html.js index 8d2d19b9fe..ccb807d6a8 100644 --- a/app/assets/javascripts/discourse/lib/html.js +++ b/app/assets/javascripts/discourse/lib/html.js @@ -1,11 +1,3 @@ -/** - Helpers to build HTML strings as well as custom fragments. - - @class HTML - @namespace Discourse - @module Discourse -**/ - var customizations = {}; Discourse.HTML = { @@ -15,9 +7,6 @@ Discourse.HTML = { using `setCustomHTML(key, html)`. This is used by a handlebars helper to find the HTML content it wants. It will also check the `PreloadStore` for any server side preloaded HTML. - - @method getCustomHTML - @param {String} key to lookup **/ getCustomHTML: function(key) { var c = customizations[key]; @@ -31,104 +20,9 @@ Discourse.HTML = { } }, - /** - Set a fragment of HTML by key. It can then be looked up with `getCustomHTML(key)`. - - @method setCustomHTML - @param {String} key to store the html - @param {String} html fragment to store - **/ + // Set a fragment of HTML by key. It can then be looked up with `getCustomHTML(key)`. setCustomHTML: function(key, html) { customizations[key] = html; - }, - - /** - Returns the CSS styles for a category - - @method categoryStyle - @param {Discourse.Category} category the category whose link we want - **/ - categoryStyle: function(category) { - var color = Em.get(category, 'color'), - textColor = Em.get(category, 'text_color'); - - if (!color && !textColor) { return; } - - // Add the custom style if we need to - var style = ""; - if (color) { style += "background-color: #" + color + "; "; } - if (textColor) { style += "color: #" + textColor + "; "; } - return style; - }, - - /** - Create a category badge - - @method categoryBadge - @param {Discourse.Category} category the category whose link we want - @param {Object} opts The options for the category link - @param {Boolean} opts.allowUncategorized Whether we allow rendering of the uncategorized category (default false) - @param {Boolean} opts.showParent Whether to visually show whether category is a sub-category (default false) - @param {Boolean} opts.link Whether this category badge should link to the category (default true) - @param {String} opts.extraClasses add this string to the class attribute of the badge - @returns {String} the html category badge - **/ - categoryBadge: function(category, opts) { - opts = opts || {}; - - if ((!category) || - (!opts.allowUncategorized && - Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id") && - Discourse.SiteSettings.suppress_uncategorized_badge - ) - ) return ""; - - var name = Em.get(category, 'name'), - description = Em.get(category, 'description_text'), - restricted = Em.get(category, 'read_restricted'), - url = Discourse.getURL("/c/") + Discourse.Category.slugFor(category), - elem = (opts.link === false ? 'span' : 'a'), - extraClasses = (opts.extraClasses ? (' ' + opts.extraClasses) : ''), - html = "<" + elem + " href=\"" + (opts.link === false ? '' : url) + "\" ", - categoryStyle; - - // Parent stripe implies onlyStripe - if (opts.onlyStripe) { opts.showParent = true; } - - html += "data-drop-close=\"true\" class=\"badge-category" + (restricted ? ' restricted' : '' ) + - (opts.onlyStripe ? ' clear-badge' : '') + - extraClasses + "\" "; - name = Handlebars.Utils.escapeExpression(name); - - // Add description if we have it, without tags. Server has sanitized the description value. - if (description) html += "title=\"" + Handlebars.Utils.escapeExpression(description) + "\" "; - - if (!opts.onlyStripe) { - categoryStyle = Discourse.HTML.categoryStyle(category); - if (categoryStyle) { - html += "style=\"" + categoryStyle + "\" "; - } - } - - if (restricted) { - html += ">
    " + name + "
    "; - } else { - html += ">" + name + ""; - } - - if (opts.onlyStripe || (opts.showParent && category.get('parent_category_id'))) { - var parent = Discourse.Category.findById(category.get('parent_category_id')); - if (!parent) { parent = category; } - - categoryStyle = Discourse.HTML.categoryStyle(opts.onlyStripe ? category : parent) || ''; - html = "<" + elem + " class='badge-category-parent" + extraClasses + "' style=\"" + categoryStyle + - "\" href=\"" + (opts.link === false ? '' : url) + "\">" + - (Em.get(parent, 'read_restricted') ? " " : "") + - Em.get(parent, 'name') + "" + - html + ""; - } - - return html; } }; diff --git a/app/assets/javascripts/discourse/lib/keyboard_shortcuts.js b/app/assets/javascripts/discourse/lib/keyboard_shortcuts.js index f96e7fd33c..76ec7b4c90 100644 --- a/app/assets/javascripts/discourse/lib/keyboard_shortcuts.js +++ b/app/assets/javascripts/discourse/lib/keyboard_shortcuts.js @@ -8,7 +8,6 @@ var PATH_BINDINGS = { }, SELECTED_POST_BINDINGS = { - 'b': 'toggleBookmark', 'd': 'deletePost', 'e': 'editPost', 'l': 'toggleLike', @@ -50,7 +49,8 @@ var PATH_BINDINGS = { 'ctrl+f': 'showBuiltinSearch', 'command+f': 'showBuiltinSearch', '?': 'showHelpModal', // open keyboard shortcut help - 'q': 'quoteReply' + 'q': 'quoteReply', + 'b': 'toggleBookmark' }; @@ -65,6 +65,11 @@ Discourse.KeyboardShortcuts = Ember.Object.createWithMixins({ _.each(FUNCTION_BINDINGS, this._bindToFunction, this); }, + toggleBookmark: function(){ + this.sendToSelectedPost('toggleBookmark'); + this.sendToTopicListItemView('toggleBookmark'); + }, + quoteReply: function(){ $('.topic-post.selected button.create').click(); // lazy but should work for now @@ -157,19 +162,32 @@ Discourse.KeyboardShortcuts = Ember.Object.createWithMixins({ Discourse.__container__.lookup('controller:application').send('showKeyboardShortcutsHelp'); }, - _bindToSelectedPost: function(action, binding) { + sendToTopicListItemView: function(action){ + var elem = $('tr.selected.topic-list-item.ember-view')[0]; + if(elem){ + var view = Ember.View.views[elem.id]; + view.send(action); + } + }, + + sendToSelectedPost: function(action){ var container = this.container; + // TODO: We should keep track of the post without a CSS class + var selectedPostId = parseInt($('.topic-post.selected article.boxed').data('post-id'), 10); + if (selectedPostId) { + var topicController = container.lookup('controller:topic'), + post = topicController.get('postStream.posts').findBy('id', selectedPostId); + if (post) { + topicController.send(action, post); + } + } + }, + + _bindToSelectedPost: function(action, binding) { + var self = this; this.keyTrapper.bind(binding, function() { - // TODO: We should keep track of the post without a CSS class - var selectedPostId = parseInt($('.topic-post.selected article.boxed').data('post-id'), 10); - if (selectedPostId) { - var topicController = container.lookup('controller:topic'), - post = topicController.get('postStream.posts').findBy('id', selectedPostId); - if (post) { - topicController.send(action, post); - } - } + self.sendToSelectedPost(action); }); }, @@ -244,9 +262,14 @@ Discourse.KeyboardShortcuts = Ember.Object.createWithMixins({ var $article = $articles.eq(index + direction); if ($article.size() > 0) { + $articles.removeClass('selected'); $article.addClass('selected'); + if($article.is('.topic-list-item')){ + this.sendToTopicListItemView('select'); + } + if ($article.is('.topic-post')) { var tabLoc = $article.find('a.tabLoc'); if (tabLoc.length === 0) { diff --git a/app/assets/javascripts/discourse/lib/url.js b/app/assets/javascripts/discourse/lib/url.js index e196943e52..2c6c212a28 100644 --- a/app/assets/javascripts/discourse/lib/url.js +++ b/app/assets/javascripts/discourse/lib/url.js @@ -2,7 +2,7 @@ var jumpScheduled = false, rewrites = []; -Discourse.URL = Em.Object.createWithMixins({ +Discourse.URL = Ember.Object.createWithMixins({ // Used for matching a topic TOPIC_REGEXP: /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/, @@ -203,7 +203,6 @@ Discourse.URL = Em.Object.createWithMixins({ var container = Discourse.__container__, topicController = container.lookup('controller:topic'), - topicProgressController = container.lookup('controller:topic-progress'), opts = {}, postStream = topicController.get('postStream'); @@ -211,16 +210,18 @@ Discourse.URL = Em.Object.createWithMixins({ if (path.match(/last$/)) { opts.nearPost = topicController.get('highest_post_number'); } var closest = opts.nearPost || 1; + var self = this; postStream.refresh(opts).then(function() { topicController.setProperties({ currentPost: closest, - highlightOnInsert: closest, enteredAt: new Date().getTime().toString() }); var closestPost = postStream.closestPostForPostNumber(closest), - progress = postStream.progressIndexOfPost(closestPost); - topicProgressController.set('progressPosition', progress); - Discourse.PostView.considerHighlighting(topicController, closest); + progress = postStream.progressIndexOfPost(closestPost), + progressController = container.lookup('controller:topic-progress'); + + progressController.set('progressPosition', progress); + self.appEvents.trigger('post:highlight', closest); }).then(function() { Discourse.URL.jumpToPost(closest); }); diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index 8fe483a16d..243910e019 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -162,10 +162,13 @@ Discourse.Utilities = { } var upload = files[0]; - var type = Discourse.Utilities.isAnImage(upload.name) ? 'image' : 'attachment'; // CHROME ONLY: if the image was pasted, sets its name to a default one - if (upload instanceof Blob && !(upload instanceof File) && upload.type === "image/png") { upload.name = "blob.png"; } + if (typeof Blob !== "undefined" && typeof File !== "undefined") { + if (upload instanceof Blob && !(upload instanceof File) && upload.type === "image/png") { upload.name = "blob.png"; } + } + + var type = Discourse.Utilities.isAnImage(upload.name) ? 'image' : 'attachment'; return Discourse.Utilities.validateUploadedFile(upload, type, bypassNewUserRestriction); }, @@ -286,7 +289,7 @@ Discourse.Utilities = { // deal with meaningful errors first if (data.jqXHR) { switch (data.jqXHR.status) { - // cancel from the user + // cancelled by the user case 0: return; // entity too large, usually returned from the web server diff --git a/app/assets/javascripts/discourse/mixins/add-category-class.js.es6 b/app/assets/javascripts/discourse/mixins/add-category-class.js.es6 index b0fd0544e7..209b105f36 100644 --- a/app/assets/javascripts/discourse/mixins/add-category-class.js.es6 +++ b/app/assets/javascripts/discourse/mixins/add-category-class.js.es6 @@ -1,23 +1,23 @@ -// Mix this in to a view that has a `categoryId` property to automatically +// Mix this in to a view that has a `categorySlug` property to automatically // add it to the body as the view is entered / left / model is changed. // This is used for keeping the `body` style in sync for the background image. export default { - _observeOnce: function() { this.get('categoryId'); }.on('init'), + _enterView: function() { this.get('categorySlug'); }.on('init'), _removeClasses: function() { $('body').removeClass(function(idx, css) { - return (css.match(/\bcategory-\d+/g) || []).join(' '); + return (css.match(/\bcategory-\S+/g) || []).join(' '); }); }, _categoryChanged: function() { - var categoryId = this.get('categoryId'); + var categorySlug = this.get('categorySlug'); this._removeClasses(); - if (categoryId) { - $('body').addClass('category-' + categoryId); + if (categorySlug) { + $('body').addClass('category-' + categorySlug); } - }.observes('categoryId'), + }.observes('categorySlug'), _leaveView: function() { this._removeClasses(); }.on('willDestroyElement') }; diff --git a/app/assets/javascripts/discourse/mixins/ajax.js b/app/assets/javascripts/discourse/mixins/ajax.js index b9f84255f5..c32abf8b18 100644 --- a/app/assets/javascripts/discourse/mixins/ajax.js +++ b/app/assets/javascripts/discourse/mixins/ajax.js @@ -63,6 +63,8 @@ Discourse.Ajax = Em.Mixin.create({ xhr.jqTextStatus = textStatus; xhr.requestedUrl = url; + // TODO is this sequence correct? we are calling catch defined externally before + // the error that was defined inline, it should probably be in reverse Ember.run(null, reject, xhr); if (oldError) oldError(xhr); }; diff --git a/app/assets/javascripts/discourse/mixins/upload.js.es6 b/app/assets/javascripts/discourse/mixins/upload.js.es6 index f9b98024f4..dceab04b34 100644 --- a/app/assets/javascripts/discourse/mixins/upload.js.es6 +++ b/app/assets/javascripts/discourse/mixins/upload.js.es6 @@ -11,17 +11,15 @@ export default Em.Mixin.create({ }, _initializeUploader: function() { - // NOTE: we can't cache this as fileupload replaces the input after upload - // cf. https://github.com/blueimp/jQuery-File-Upload/wiki/Frequently-Asked-Questions#why-is-the-file-input-field-cloned-and-replaced-after-each-selection - var $upload = this.$('input[type=file]'), - self = this; + var $upload = this.$(), + self = this, + csrf = Discourse.Session.currentProp("csrfToken"); $upload.fileupload({ - url: this.get('uploadUrl'), + url: this.get('uploadUrl') + ".json?authenticity_token=" + encodeURIComponent(csrf), dataType: "json", - fileInput: $upload, - dropZone: this.$(), - pasteZone: this.$() + dropZone: $upload, + pasteZone: $upload }); $upload.on('fileuploadsubmit', function (e, data) { @@ -39,14 +37,20 @@ export default Em.Mixin.create({ }); $upload.on("fileuploaddone", function(e, data) { - if(data.result.url) { - self.uploadDone(data); - } else { - if (data.result.message) { - bootbox.alert(data.result.message); + if (data.result) { + if (data.result.url) { + self.uploadDone(data); } else { - bootbox.alert(I18n.t('post.errors.upload')); + if (data.result.message) { + bootbox.alert(data.result.message); + } else if (data.result.length > 0) { + bootbox.alert(data.result.join("\n")); + } else { + bootbox.alert(I18n.t('post.errors.upload')); + } } + } else { + bootbox.alert(I18n.t('post.errors.upload')); } }); @@ -60,12 +64,9 @@ export default Em.Mixin.create({ }.on('didInsertElement'), _destroyUploader: function() { - this.$('input[type=file]').fileupload('destroy'); - }.on('willDestroyElement'), - - actions: { - selectFile: function() { - this.$('input[type=file]').click(); - } - } + var $upload = this.$(); + try { $upload.fileupload('destroy'); } + catch (e) { /* wasn't initialized yet */ } + $upload.off(); + }.on('willDestroyElement') }); diff --git a/app/assets/javascripts/discourse/mixins/url-refresh.js.es6 b/app/assets/javascripts/discourse/mixins/url-refresh.js.es6 index 389f60c76d..e5ab51cb3d 100644 --- a/app/assets/javascripts/discourse/mixins/url-refresh.js.es6 +++ b/app/assets/javascripts/discourse/mixins/url-refresh.js.es6 @@ -4,16 +4,9 @@ // // This is useful if you want to get around Ember's default // behavior of not refreshing when navigating to the same place. -export default Em.Mixin.create({ - _initURLRefresh: function() { - this.appEvents.on('url:refresh', this, '_urlRefresh'); - }.on('didInsertElement'), - _tearDownURLRefresh: function() { - this.appEvents.off('url:refresh', this, '_urlRefresh'); - }.on('willDestroyElement'), +import { createViewListener } from 'discourse/lib/app-events'; - _urlRefresh: function() { - this.get('controller').send('refresh'); - } +export default createViewListener('url:refresh', function() { + this.get('controller').send('refresh'); }); diff --git a/app/assets/javascripts/discourse/models/_post.js b/app/assets/javascripts/discourse/models/_post.js index 59077aefcd..7eeb44b25e 100644 --- a/app/assets/javascripts/discourse/models/_post.js +++ b/app/assets/javascripts/discourse/models/_post.js @@ -470,7 +470,7 @@ Discourse.Post.reopenClass({ loadRevision: function(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) { - return Em.Object.create(result); + return Ember.Object.create(result); }); }, diff --git a/app/assets/javascripts/discourse/models/category.js b/app/assets/javascripts/discourse/models/category.js index 3f8bb6edb6..e7bc7dd6fa 100644 --- a/app/assets/javascripts/discourse/models/category.js +++ b/app/assets/javascripts/discourse/models/category.js @@ -232,6 +232,7 @@ Discourse.Category.reopenClass({ }, findById: function(id) { + if (!id) { return; } return Discourse.Category.idMap()[id]; }, diff --git a/app/assets/javascripts/discourse/models/category_list.js b/app/assets/javascripts/discourse/models/category_list.js index ee8b08c158..bd1e1711fb 100644 --- a/app/assets/javascripts/discourse/models/category_list.js +++ b/app/assets/javascripts/discourse/models/category_list.js @@ -1,11 +1,3 @@ -/** - A data model for containing a list of categories - - @class CategoryList - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ Discourse.CategoryList = Ember.ArrayProxy.extend({ init: function() { this.set('content', []); @@ -50,7 +42,8 @@ Discourse.CategoryList.reopenClass({ var self = this; return Discourse.ajax('/categories.json?parent_category_id=' + category.get('id')).then(function(result) { return Discourse.CategoryList.create({ - categories: self.categoriesFrom(result) + categories: self.categoriesFrom(result), + parentCategory: category }); }); }, diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 1e25f9b67b..3103c80b19 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -328,36 +328,6 @@ Discourse.Composer = Discourse.Model.extend({ Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); }, - importQuote: function() { - var postStream = this.get('topic.postStream'), - postId = this.get('post.id'); - - if (!postId && postStream) { - postId = postStream.get('firstPostId'); - } - - // If we're editing a post, fetch the reply when importing a quote - if (this.get('editingPost')) { - var replyToPostNumber = this.get('post.reply_to_post_number'); - if (replyToPostNumber) { - var replyPost = postStream.get('posts').findBy('post_number', replyToPostNumber); - if (replyPost) { - postId = replyPost.get('id'); - } - } - } - - // If there is no current post, use the post id from the stream - if (postId) { - this.set('loading', true); - var composer = this; - return Discourse.Post.load(postId).then(function(post) { - composer.appendText(Discourse.Quote.build(post, post.get('raw'))); - composer.set('loading', false); - }); - } - }, - /* Open a composer @@ -467,14 +437,19 @@ Discourse.Composer = Discourse.Model.extend({ editPost: function(opts) { var post = this.get('post'), oldCooked = post.get('cooked'), - composer = this; + self = this, + promise; - // Update the title if we've changed it - if (this.get('title') && post.get('post_number') === 1) { + // Update the title if we've changed it, otherwise consider it a + // successful resolved promise + if (this.get('title') && + post.get('post_number') === 1 && + this.get('topic.details.can_edit')) { var topicProps = this.getProperties(Object.keys(_edit_topic_serializer)); - - Discourse.Topic.update(this.get('topic'), topicProps); + promise = Discourse.Topic.update(this.get('topic'), topicProps); + } else { + promise = Ember.RSVP.resolve(); } post.setProperties({ @@ -485,19 +460,19 @@ Discourse.Composer = Discourse.Model.extend({ }); this.set('composeState', CLOSED); - return new Ember.RSVP.Promise(function(resolve, reject) { - post.save(function(result) { + return promise.then(function() { + return post.save(function(result) { post.updateFromPost(result); - composer.clearState(); - }, function(error) { + self.clearState(); + }).catch(function(error) { var response = $.parseJSON(error.responseText); if (response && response.errors) { - reject(response.errors[0]); + return(response.errors[0]); } else { - reject(I18n.t('generic_error')); + return(I18n.t('generic_error')); } post.set('cooked', oldCooked); - composer.set('composeState', OPEN); + self.set('composeState', OPEN); }); }); }, @@ -530,9 +505,11 @@ Discourse.Composer = Discourse.Model.extend({ imageSizes: opts.imageSizes, cooked: this.getCookedHtml(), reply_count: 0, + name: currentUser.get('name'), display_username: currentUser.get('name'), username: currentUser.get('username'), user_id: currentUser.get('id'), + user_title: currentUser.get('title'), uploaded_avatar_id: currentUser.get('uploaded_avatar_id'), user_custom_fields: currentUser.get('custom_fields'), post_type: Discourse.Site.currentProp('post_types.regular'), diff --git a/app/assets/javascripts/discourse/models/group.js b/app/assets/javascripts/discourse/models/group.js index b3fc5798b9..5d7ff91c2b 100644 --- a/app/assets/javascripts/discourse/models/group.js +++ b/app/assets/javascripts/discourse/models/group.js @@ -11,6 +11,15 @@ Discourse.Group = Discourse.Model.extend({ offset: 0, user_count: 0, + emailDomains: function() { + var value = this.get("automatic_membership_email_domains"); + return Em.isEmpty(value) ? "" : value; + }.property("automatic_membership_email_domains"), + + type: function() { + return this.get("automatic") ? "automatic" : "custom"; + }.property("automatic"), + userCountDisplay: function(){ var c = this.get('user_count'); // don't display zero its ugly @@ -20,7 +29,8 @@ Discourse.Group = Discourse.Model.extend({ findMembers: function() { if (Em.isEmpty(this.get('name'))) { return ; } - var self = this, offset = Math.min(this.get("user_count"), Math.max(this.get("offset"), 0)); + var self = this, + offset = Math.min(this.get("user_count"), Math.max(this.get("offset"), 0)); return Discourse.ajax('/groups/' + this.get('name') + '/members.json', { data: { @@ -63,7 +73,9 @@ Discourse.Group = Discourse.Model.extend({ return { name: this.get('name'), alias_level: this.get('alias_level'), - visible: !!this.get('visible') + visible: !!this.get('visible'), + automatic_membership_email_domains: this.get('emailDomains'), + automatic_membership_retroactive: !!this.get('automatic_membership_retroactive') }; }, @@ -100,7 +112,7 @@ Discourse.Group = Discourse.Model.extend({ Discourse.Group.reopenClass({ findAll: function(opts){ - return Discourse.ajax("/admin/groups.json", { data: opts }).then(function(groups){ + return Discourse.ajax("/admin/groups.json", { data: opts }).then(function (groups){ return groups.map(function(g) { return Discourse.Group.create(g); }); }); }, @@ -112,8 +124,8 @@ Discourse.Group.reopenClass({ }, find: function(name) { - return Discourse.ajax("/groups/" + name + ".json").then(function(g) { - return Discourse.Group.create(g.basic_group); + return Discourse.ajax("/groups/" + name + ".json").then(function (result) { + return Discourse.Group.create(result.basic_group); }); } }); diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js index dc20e44308..3103726655 100644 --- a/app/assets/javascripts/discourse/models/topic.js +++ b/app/assets/javascripts/discourse/models/topic.js @@ -188,15 +188,23 @@ Discourse.Topic = Discourse.Model.extend({ return Discourse.ajax('/t/' + this.get('id') + '/bookmark', { type: 'PUT', - data: { bookmarked: self.get('bookmarked') } - }).then(null, function (error) { - self.toggleProperty('bookmarked'); - if (self.get("postStream.firstPostPresent")) { firstPost.toggleProperty('bookmarked'); } + data: { bookmarked: self.get('bookmarked') }, + error: function(error){ + self.toggleProperty('bookmarked'); + if (self.get("postStream.firstPostPresent")) { firstPost.toggleProperty('bookmarked'); } - if (error && error.responseText) { - bootbox.alert($.parseJSON(error.responseText).errors); - } else { - bootbox.alert(I18n.t('generic_error')); + var showGenericError = true; + + if (error && error.responseText) { + try { + bootbox.alert($.parseJSON(error.responseText).errors); + showGenericError = false; + } catch(e){} + } + + if(showGenericError){ + bootbox.alert(I18n.t('generic_error')); + } } }); }, diff --git a/app/assets/javascripts/discourse/models/topic_list.js b/app/assets/javascripts/discourse/models/topic_list.js index 4d70739bd6..01e3f12697 100644 --- a/app/assets/javascripts/discourse/models/topic_list.js +++ b/app/assets/javascripts/discourse/models/topic_list.js @@ -1,12 +1,3 @@ -/** - A data model representing a list of topics - - @class TopicList - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ - function finderFor(filter, params) { return function() { var url = Discourse.getURL("/") + filter + ".json"; @@ -264,6 +255,12 @@ Discourse.TopicList.reopenClass({ return PreloadStore.getAndRemove("topic_list_" + filter, finderFor(filter, params)).then(function(result) { return Discourse.TopicList.from(result, filter, params); }); + }, + + // Sets `hideCategory` if all topics in the last have a particular category + hideUniformCategory: function(list, category) { + var hideCategory = !list.get('topics').any(function (t) { return t.get('category') !== category; }); + list.set('hideCategory', hideCategory); } }); diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 7a5bd5c37c..c650d1704e 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -72,10 +72,9 @@ Discourse.User = Discourse.Model.extend({ @type {String} **/ profileBackground: function() { - var background = this.get('profile_background'); - if(Em.isEmpty(background) || !Discourse.SiteSettings.allow_profile_backgrounds) { return; } - - return 'background-image: url(' + background + ')'; + var url = this.get('profile_background'); + if (Em.isEmpty(url) || !Discourse.SiteSettings.allow_profile_backgrounds) { return; } + return 'background-image: url(' + Discourse.getURLWithCDN(url) + ')'; }.property('profile_background'), /** @@ -352,10 +351,13 @@ Discourse.User = Discourse.Model.extend({ Change avatar selection */ pickAvatar: function(uploadId) { - this.set("uploaded_avatar_id", uploadId); + var self = this; + return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/pick", { type: 'PUT', data: { upload_id: uploadId } + }).then(function(){ + self.set('uploaded_avatar_id', uploadId); }); }, @@ -442,6 +444,7 @@ Discourse.User.reopenClass(Discourse.Singleton, { avatarTemplate: function(username, uploadedAvatarId) { var url; + if (uploadedAvatarId) { url = "/user_avatar/" + Discourse.BaseUrl + @@ -456,11 +459,7 @@ Discourse.User.reopenClass(Discourse.Singleton, { Discourse.LetterAvatarVersion + ".png"; } - url = Discourse.getURL(url); - if (Discourse.CDN) { - url = Discourse.CDN + url; - } - return url; + return Discourse.getURLWithCDN(url); }, /** diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 83f8a53a7e..34ca877387 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -132,18 +132,13 @@ var ApplicationRoute = Discourse.Route.extend({ }, editCategory: function(category) { - var router = this; + var self = this; - if (category.get('isUncategorizedCategory')) { - Discourse.Route.showModal(router, 'editCategory', category); - router.controllerFor('editCategory').set('selectedTab', 'general'); - } else { - Discourse.Category.reloadById(category.get('id')).then(function (c) { - Discourse.Site.current().updateCategory(c); - Discourse.Route.showModal(router, 'editCategory', c); - router.controllerFor('editCategory').set('selectedTab', 'general'); - }); - } + Discourse.Category.reloadById(category.get('id')).then(function (c) { + self.site.updateCategory(c); + Discourse.Route.showModal(self, 'editCategory', c); + self.controllerFor('editCategory').set('selectedTab', 'general'); + }); }, /** diff --git a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 index 2cbef30cf5..ae70acd7d6 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -54,9 +54,7 @@ export default function(filter, params) { extras = { cached: this.isPoppedState(transition) }; return Discourse.TopicList.list(listFilter, findOpts, extras).then(function(list) { - // If all the categories are the same, we can hide them - var hideCategory = !list.get('topics').find(function (t) { return t.get('category') !== model; }); - list.set('hideCategory', hideCategory); + Discourse.TopicList.hideUniformCategory(list, model); self.set('topics', list); }); }, diff --git a/app/assets/javascripts/discourse/routes/discourse_route.js b/app/assets/javascripts/discourse/routes/discourse_route.js index c3af37a09c..75be93d63a 100644 --- a/app/assets/javascripts/discourse/routes/discourse_route.js +++ b/app/assets/javascripts/discourse/routes/discourse_route.js @@ -89,23 +89,53 @@ Discourse.Route.reopenClass({ }, mapRoutes: function() { + var resources = {}; + + // If a module is defined as `route-map` in discourse or a plugin, its routes + // will be built automatically. You can supply a `resource` property to + // automatically put it in that resource, such as `admin`. That way plugins + // can define admin routes. + Ember.keys(requirejs._eak_seen).forEach(function(key) { + if (/route-map$/.test(key)) { + var module = require(key, null, null, true); + if (!module || !module.default) { throw new Error(key + ' must export a route map.'); } + + var mapObj = module.default; + if (typeof mapObj === 'function') { + mapObj = { resource: 'root', map: mapObj }; + } + + if (!resources[mapObj.resource]) { resources[mapObj.resource] = []; } + resources[mapObj.resource].push(mapObj.map); + } + }); + Discourse.Router.map(function() { var router = this; + // Do the root resources first + if (resources.root) { + resources.root.forEach(function(m) { + m.call(router); + }); + delete resources.root; + } + + // Apply other resources next + Object.keys(resources).forEach(function(r) { + router.resource(r, function() { + var res = this; + resources[r].forEach(function(m) { + m.call(res); + }); + }); + }); + if (routeBuilder) { Ember.warn("The Discourse `routeBuilder` is deprecated. Export a `route-map` instead"); routeBuilder.call(router); } - // If a module is defined as `route-map` in discourse or a plugin, its routes - // will be built automatically. - Ember.keys(requirejs._eak_seen).forEach(function(key) { - if (/route-map$/.test(key)) { - var module = require(key, null, null, true); - if (!module) { throw new Error(key + ' must export a map function.'); } - module.default.call(router); - } - }); this.route('unknown', {path: '*path'}); }); diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6 index 6ad9efb529..3f28b1b409 100644 --- a/app/assets/javascripts/discourse/routes/preferences.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences.js.es6 @@ -45,15 +45,17 @@ export default RestrictedUserRoute.extend(ShowFooter, { // sends the information to the server if it has changed if (avatarSelector.get('selectedUploadId') !== user.get('uploaded_avatar_id')) { - user.pickAvatar(avatarSelector.get('selectedUploadId')); + user.pickAvatar(avatarSelector.get('selectedUploadId')) + .then(function(){ + user.setProperties(avatarSelector.getProperties( + 'system_avatar_upload_id', + 'gravatar_avatar_upload_id', + 'custom_avatar_upload_id' + )); + }); } // saves the data back - user.setProperties(avatarSelector.getProperties( - 'system_avatar_upload_id', - 'gravatar_avatar_upload_id', - 'custom_avatar_upload_id' - )); avatarSelector.send('closeModal'); }, diff --git a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 index 0f648039ba..9c414daac0 100644 --- a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 @@ -1,5 +1,4 @@ // This route is used for retrieving a topic based on params - export default Discourse.Route.extend({ setupController: function(controller, params) { @@ -15,6 +14,7 @@ export default Discourse.Route.extend({ // I sincerely hope no topic gets this many posts if (params.nearPost === "last") { params.nearPost = 999999999; } + var self = this; postStream.refresh(params).then(function () { // TODO we are seeing errors where closest post is null and this is exploding @@ -28,13 +28,17 @@ export default Discourse.Route.extend({ topicController.setProperties({ currentPost: closest, enteredAt: new Date().getTime().toString(), - highlightOnInsert: closest }); topicProgressController.setProperties({ progressPosition: progress, expanded: false }); + + // Highlight our post after the next render + Ember.run.scheduleOnce('afterRender', function() { + self.appEvents.trigger('post:highlight', closest); + }); Discourse.URL.jumpToPost(closest); if (topic.present('draft')) { diff --git a/app/assets/javascripts/discourse/templates/about.hbs b/app/assets/javascripts/discourse/templates/about.hbs index 183269be58..2fe8cfdcb6 100644 --- a/app/assets/javascripts/discourse/templates/about.hbs +++ b/app/assets/javascripts/discourse/templates/about.hbs @@ -37,26 +37,37 @@   {{i18n 'about.stat.all_time'}} {{i18n 'about.stat.last_7_days'}} + {{i18n 'about.stat.last_30_days'}} {{i18n 'about.topic_count'}} {{number stats.topic_count}} {{number stats.topics_7_days}} + {{number stats.topics_30_days}} {{i18n 'about.post_count'}} {{number stats.post_count}} {{number stats.posts_7_days}} + {{number stats.posts_30_days}} {{i18n 'about.user_count'}} {{number stats.user_count}} {{number stats.users_7_days}} + {{number stats.users_30_days}} + + + {{i18n 'about.active_user_count'}} + + {{number stats.active_users_7_days}} + {{number stats.active_users_30_days}} {{i18n 'about.like_count'}} {{number stats.like_count}} {{number stats.likes_7_days}} + {{number stats.likes_30_days}} diff --git a/app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs index 5a3ec802d7..76ba9ebddf 100644 --- a/app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs +++ b/app/assets/javascripts/discourse/templates/category-group-autocomplete.raw.hbs @@ -1,7 +1,7 @@
    diff --git a/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs b/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs index 028b30eab5..21761c67bd 100644 --- a/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs +++ b/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs @@ -1,7 +1,7 @@ - - + {{#if uploading}} {{i18n 'upload_selector.uploading'}} {{view.uploadProgress}}% {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/category-drop.hbs b/app/assets/javascripts/discourse/templates/components/category-drop.hbs index 2f26a90ccb..02a184e34b 100644 --- a/app/assets/javascripts/discourse/templates/components/category-drop.hbs +++ b/app/assets/javascripts/discourse/templates/components/category-drop.hbs @@ -1,27 +1,27 @@ {{#if category}} - + {{#if category.read_restricted}} - + {{fa-icon "lock"}} {{/if}} {{category.name}} {{else}} {{#if noSubcategories}} - {{i18n 'categories.no_subcategory'}} + {{i18n 'categories.no_subcategory'}} {{else}} - {{allCategoriesLabel}} + {{allCategoriesLabel}} {{/if}} {{/if}} {{#if categories}} - +
    {{#if subCategory}} - + {{/if}} {{#if renderCategories}} - {{#each c in categories}}
    {{category-link c allowUncategorized=true}}
    {{/each}} + {{#each c in categories}}
    {{category-link c allowUncategorized=true hideParent=subCategory}}
    {{/each}} {{/if}}
    {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/disabled-icon.hbs b/app/assets/javascripts/discourse/templates/components/disabled-icon.hbs new file mode 100644 index 0000000000..b7749fecee --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/disabled-icon.hbs @@ -0,0 +1,5 @@ +{{fa-icon icon modifier="stack-2x"}} + +{{#if disabled}} + {{fa-icon "ban" modifier="stack-2x"}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/emoji-uploader.hbs b/app/assets/javascripts/discourse/templates/components/emoji-uploader.hbs index 8d0166e736..8d9b297cf9 100644 --- a/app/assets/javascripts/discourse/templates/components/emoji-uploader.hbs +++ b/app/assets/javascripts/discourse/templates/components/emoji-uploader.hbs @@ -1,6 +1,6 @@ {{text-field name="name" placeholderKey="admin.emoji.name" value=name}} - - + + diff --git a/app/assets/javascripts/discourse/templates/components/image-uploader.hbs b/app/assets/javascripts/discourse/templates/components/image-uploader.hbs index ccb6a03ae2..32c0dce129 100644 --- a/app/assets/javascripts/discourse/templates/components/image-uploader.hbs +++ b/app/assets/javascripts/discourse/templates/components/image-uploader.hbs @@ -1,7 +1,9 @@ -
    - + {{#if backgroundStyle}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/topic-category.hbs b/app/assets/javascripts/discourse/templates/components/topic-category.hbs new file mode 100644 index 0000000000..8a914440e0 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/topic-category.hbs @@ -0,0 +1,6 @@ +{{#if topic.category.parentCategory}} + {{bound-category-link topic.category.parentCategory}} +{{/if}} +{{bound-category-link topic.category hideParent=true}} + +{{plugin-outlet "topic-category"}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index d28d450e12..9226c11b3b 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -48,7 +48,7 @@ so I'm going to stop rendering it until we figure out what's up {{#if showWarning}}
    @@ -101,6 +101,7 @@ so I'm going to stop rendering it until we figure out what's up {{#if currentUser}}
    + {{plugin-outlet "composer-fields-below"}} {{i18n 'cancel'}}
    diff --git a/app/assets/javascripts/discourse/templates/discovery/categories.hbs b/app/assets/javascripts/discourse/templates/discovery/categories.hbs index 691b4d0473..192cc7a77d 100644 --- a/app/assets/javascripts/discourse/templates/discovery/categories.hbs +++ b/app/assets/javascripts/discourse/templates/discovery/categories.hbs @@ -10,71 +10,71 @@ {{#each c in categories}} - - -
    -
    - {{category-title-link category=c}} - {{#if c.unreadTopics}} - {{i18n 'filters.unread.lower_title_with_count' count=c.unreadTopics}} - {{/if}} - {{#if c.newTopics}} - {{i18n 'filters.new.lower_title_with_count' count=c.newTopics}} - {{/if}} -
    -
    -
    - {{#if c.description_excerpt}} -
    - {{{c.description_excerpt}}} -
    - {{/if}} - {{#if c.subcategories}} -
    - {{#each s in c.subcategories}} - {{category-link s showParent="true" onlyStripe="true"}} - {{#if s.unreadTopics}} - {{unbound s.unreadTopics}} + + +
    +
    + {{category-title-link category=c}} + {{#if c.unreadTopics}} + {{i18n 'filters.unread.lower_title_with_count' count=c.unreadTopics}} {{/if}} - {{#if s.newTopics}} - {{unbound s.newTopics}} + {{#if c.newTopics}} + {{i18n 'filters.new.lower_title_with_count' count=c.newTopics}} {{/if}} - {{/each}} +
    +
    - {{/if}} - - - {{#each f in c.featuredTopics}} -
    diff --git a/app/assets/javascripts/discourse/templates/group/members.hbs b/app/assets/javascripts/discourse/templates/group/members.hbs index 16728d740e..6d362606d8 100644 --- a/app/assets/javascripts/discourse/templates/group/members.hbs +++ b/app/assets/javascripts/discourse/templates/group/members.hbs @@ -4,7 +4,7 @@ {{i18n 'last_seen'}} {{#each m in members}} - + {{avatar m imageSize="large"}} @@ -16,7 +16,6 @@ {{bound-date m.last_seen_at}} -
    {{/each}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/header.hbs b/app/assets/javascripts/discourse/templates/header.hbs index d7f3412e2f..7eec139b08 100644 --- a/app/assets/javascripts/discourse/templates/header.hbs +++ b/app/assets/javascripts/discourse/templates/header.hbs @@ -6,13 +6,9 @@
    {{#unless currentUser}} {{#if showSignUpButton}} - + {{d-button action="showCreateAccount" class="btn-primary btn-small sign-up-button" label="sign_up"}} {{/if}} - + {{d-button action="showLogin" class="btn-primary btn-small login-button" icon="user" label="log_in"}} {{/unless}}
    diff --git a/app/assets/javascripts/discourse/templates/list/category-column.raw.hbs b/app/assets/javascripts/discourse/templates/list/category-column.raw.hbs index 614f502ba2..10c175aac6 100644 --- a/app/assets/javascripts/discourse/templates/list/category-column.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/category-column.raw.hbs @@ -1 +1 @@ -{{category-link category showParent="true"}} +{{category-link category}} 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 8f04d3a242..99aa9d66bf 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 @@ -32,7 +32,7 @@
    {{#unless controller.hideCategory}}
    - {{category-link t.category showParent="true"}} + {{category-link t.category}}
    {{/unless}} {{#if controller.showParticipants}} diff --git a/app/assets/javascripts/discourse/templates/mobile/discovery/categories.hbs b/app/assets/javascripts/discourse/templates/mobile/discovery/categories.hbs index 2256ea394f..cbee5af14d 100644 --- a/app/assets/javascripts/discourse/templates/mobile/discovery/categories.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/discovery/categories.hbs @@ -46,7 +46,7 @@
    {{#each subcategory in c.subcategories}} - {{category-link subcategory showParent="true"}} + {{category-link subcategory}} {{/each}}
    diff --git a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs index c09ae8a689..984a40198a 100644 --- a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs @@ -14,7 +14,6 @@ {{#if hasTopics}} {{topic-list - skipHeader=true showPosters=true currentUser=currentUser hideCategory=hideCategory diff --git a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs index 6f8fbbe5b8..f52e48ba43 100644 --- a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs @@ -11,7 +11,7 @@
    {{#unless controller.hideCategory}}
    - {{category-link content.category showParent="true"}} + {{category-link content.category}}
    {{/unless}} diff --git a/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs b/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs index 7931c5ef57..2722479ed3 100644 --- a/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs +++ b/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs @@ -15,7 +15,7 @@ {{#if subCategories}} {{#each s in subCategories}} - {{category-badge s}} + {{category-badge s hideParent="true"}} {{/each}} {{else}} diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index 97f648034d..f31794f869 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -40,12 +40,12 @@ → {{bound-avatar-template user_changes.current.avatar_template "small"}} {{user_changes.current.username}} {{/if}} {{#if wiki_changes}} - — {{{wiki_diff}}} + — {{disabled-icon icon="pencil-square-o" secondary=wikiDisabled}} {{/if}} {{#if post_type_changes}} - — {{{post_type_diff}}} + — {{disabled-icon icon="shield" disabled=postTypeDisabled}} {{/if}} - {{#if category_changes}} + {{#if category_id_changes}} — {{{previousCategory}}} → {{{currentCategory}}} {{/if}} {{/unless}} @@ -53,7 +53,7 @@
    {{#if title_changes}}
    -

    {{{title_diff}}}

    +

    {{{titleDiff}}}

    {{/if}} {{#if site.mobileView}} @@ -65,22 +65,25 @@ {{/if}} {{#if wiki_changes}}
    - {{{wiki_diff}}} + {{disabled-icon icon="pencil-square-o" secondary=wikiDisabled}}
    {{/if}} {{#if post_type_changes}}
    - {{{post_type_diff}}} + {{disabled-icon icon="shield" disabled=postTypeDisabled}}
    {{/if}} - {{#if category_changes}} + {{#if category_id_changes}}
    {{{previousCategory}}} → {{{currentCategory}}}
    {{/if}} {{/if}} + + {{plugin-outlet "post-revisions"}} +
    - {{{body_diff}}} + {{{bodyDiff}}}
    diff --git a/app/assets/javascripts/discourse/templates/site-map.hbs b/app/assets/javascripts/discourse/templates/site-map.hbs index 99c1e2c433..a1efed37ab 100644 --- a/app/assets/javascripts/discourse/templates/site-map.hbs +++ b/app/assets/javascripts/discourse/templates/site-map.hbs @@ -46,7 +46,7 @@ {{#each c in categories itemController='site-map-category'}}
  • - {{category-link c allowUncategorized="true" showParent="true"}} + {{category-link c allowUncategorized="true"}} {{#if c.unreadTotal}} {{c.unreadTotal}} diff --git a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs index 50d67754ca..ef204205eb 100644 --- a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs +++ b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs @@ -2,48 +2,48 @@ diff --git a/app/assets/javascripts/discourse/templates/topic-entrance.hbs b/app/assets/javascripts/discourse/templates/topic-entrance.hbs index 8613848bb7..3dbe96f871 100644 --- a/app/assets/javascripts/discourse/templates/topic-entrance.hbs +++ b/app/assets/javascripts/discourse/templates/topic-entrance.hbs @@ -1,6 +1,7 @@ - - +{{#d-button action="enterTop" class="full no-text jump-top"}} + {{fa-icon 'caret-up'}} {{{topDate}}} +{{/d-button}} + +{{#d-button action="enterBottom" class="full no-text jump-button"}} + {{{bottomDate}}} {{fa-icon 'caret-down'}} +{{/d-button}} diff --git a/app/assets/javascripts/discourse/templates/topic-progress.hbs b/app/assets/javascripts/discourse/templates/topic-progress.hbs index ab575a6412..b891376082 100644 --- a/app/assets/javascripts/discourse/templates/topic-progress.hbs +++ b/app/assets/javascripts/discourse/templates/topic-progress.hbs @@ -1,16 +1,19 @@ {{#if expanded}} {{/if}}
  • {{#link-to 'userActivity.bookmarks' currentUser}}{{i18n 'user.bookmarks'}}{{/link-to}}
  • {{#link-to 'preferences' currentUser}}{{i18n 'user.preferences'}}{{/link-to}}
  • -
  • +
  • {{d-button action="logout" class="btn-danger right logout" icon="sign-out" label="user.log_out"}}
  • diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index a1d50a4b56..884de10217 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -171,11 +171,13 @@
    - {{preference-checkbox labelKey="user.email_digests.title" checked=email_digests}} - {{#if email_digests}} -
    - {{combo-box valueAttribute="value" content=digestFrequencies value=digest_after_days}} -
    + {{#if canReceiveDigest}} + {{preference-checkbox labelKey="user.email_digests.title" checked=email_digests}} + {{#if email_digests}} +
    + {{combo-box valueAttribute="value" content=digestFrequencies value=digest_after_days}} +
    + {{/if}} {{/if}} {{preference-checkbox labelKey="user.email_private_messages" checked=email_private_messages}} {{preference-checkbox labelKey="user.email_direct" checked=email_direct}} diff --git a/app/assets/javascripts/discourse/templates/user/stream.hbs b/app/assets/javascripts/discourse/templates/user/stream.hbs index 0d1f07c2f7..ed66367d51 100644 --- a/app/assets/javascripts/discourse/templates/user/stream.hbs +++ b/app/assets/javascripts/discourse/templates/user/stream.hbs @@ -17,9 +17,10 @@ + {{else}} +
    {{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}
    + {{#if grandChild.edit_reason}} — {{unbound grandChild.edit_reason}}{{/if}} {{/if}} -
    {{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}
    - {{#if grandChild.edit_reason}} — {{unbound grandChild.edit_reason}}{{/if}} {{/each}}
    {{/each}} diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs index 7c64ec6e69..689d272989 100644 --- a/app/assets/javascripts/discourse/templates/user/user.hbs +++ b/app/assets/javascripts/discourse/templates/user/user.hbs @@ -89,6 +89,19 @@ {{{bio_cooked}}} + {{#if publicUserFields}} +
    + {{#each uf in publicUserFields}} + {{#if uf.value}} +
    + {{uf.field.name}}: + {{uf.value}} +
    + {{/if}} + {{/each}} +
    + {{/if}} + {{plugin-outlet "user-profile-primary"}} @@ -117,7 +130,7 @@ {{#if email}} {{email}} {{else}} - + {{d-button action="checkEmail" actionParam=model icon="envelope-o" label="admin.users.check_email.text" class="btn-primary"}} {{/if}} {{/if}} @@ -130,10 +143,7 @@ {{/if}} {{#if canDeleteUser}} - + {{d-button action="adminDelete" icon="exclamation-triangle" label="user.admin_delete" class="btn-danger"}} {{/if}} {{plugin-outlet "user-profile-secondary"}} @@ -192,7 +202,7 @@ {{#if viewingSelf}}
    - + {{d-button action="exportUserArchive" label="user.download_archive" icon="download"}}
    {{/if}} diff --git a/app/assets/javascripts/discourse/views/category-chooser.js.es6 b/app/assets/javascripts/discourse/views/category-chooser.js.es6 index 46a82d0128..f0e39a8a1d 100644 --- a/app/assets/javascripts/discourse/views/category-chooser.js.es6 +++ b/app/assets/javascripts/discourse/views/category-chooser.js.es6 @@ -1,6 +1,5 @@ import ComboboxView from 'discourse/views/combo-box'; - -var badgeHtml = Discourse.HTML.categoryBadge; +import { categoryBadgeHTML } from 'discourse/helpers/category-link'; export default ComboboxView.extend({ classNames: ['combobox category-combobox'], @@ -57,10 +56,10 @@ export default ComboboxView.extend({ } if (!category) return item.text; - var result = badgeHtml(category, {showParent: false, link: false, allowUncategorized: true}), + var result = categoryBadgeHTML(category, {link: false, allowUncategorized: true, hideParent: true}), parentCategoryId = category.get('parent_category_id'); if (parentCategoryId) { - result = badgeHtml(Discourse.Category.findById(parentCategoryId), {link: false}) + " " + result; + result = categoryBadgeHTML(Discourse.Category.findById(parentCategoryId), {link: false}) + " " + result; } result += " × " + category.get('topic_count') + ""; diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index c62e86f20c..f060f28ae7 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -73,6 +73,13 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { if (pos) { self.$('.wmd-controls').css('top', $fields.height() + pos.top + 5); } + + // get the submit panel height + pos = self.$('.submit-panel').position(); + if (pos) { + self.$('.wmd-controls').css('bottom', h - pos.top + 7); + } + }); }.observes('model.composeState', 'model.action'), @@ -300,11 +307,15 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // in case it's still bound somehow this._unbindUploadTarget(); - var $uploadTarget = $('#reply-control'); + var $uploadTarget = $('#reply-control'), + csrf = Discourse.Session.currentProp('csrfToken'), + cancelledByTheUser; + // NOTE: we need both the .json extension and the CSRF token as a query parameter for IE9 $uploadTarget.fileupload({ - url: Discourse.getURL('/uploads'), + url: Discourse.getURL('/uploads.json?authenticity_token=' + encodeURIComponent(csrf)), dataType: 'json', + pasteZone: $uploadTarget }); // submit - this event is triggered for each upload @@ -317,22 +328,27 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // send - this event is triggered when the upload request is about to start $uploadTarget.on('fileuploadsend', function (e, data) { + cancelledByTheUser = false; // hide the "file selector" modal self.get('controller').send('closeModal'); - // cf. https://github.com/blueimp/jQuery-File-Upload/wiki/API#how-to-cancel-an-upload - var jqXHR = data.xhr(); - // need to wait for the link to show up in the DOM - Em.run.schedule('afterRender', function() { - // bind on the click event on the cancel link - $('#cancel-file-upload').on('click', function() { - // cancel the upload - self.set('isUploading', false); - // NOTE: this might trigger a 'fileuploadfail' event with status = 0 - if (jqXHR) jqXHR.abort(); - // unbind - $(this).off('click'); - }); - }); + // NOTE: IE9 doesn't support XHR + if (data["xhr"]) { + var jqHXR = data.xhr(); + if (jqHXR) { + // need to wait for the link to show up in the DOM + Em.run.schedule('afterRender', function() { + // bind on the click event on the cancel link + $('#cancel-file-upload').on('click', function() { + // cancel the upload + self.set('isUploading', false); + // NOTE: this might trigger a 'fileuploadfail' event with status = 0 + if (jqHXR) { cancelledByTheUser = true; jqHXR.abort(); } + // unbind + $(this).off('click'); + }); + }); + } + } }); // progress all @@ -343,14 +359,17 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // done $uploadTarget.on('fileuploaddone', function (e, data) { - // make sure we have a url - if (data.result.url) { - var markdown = Discourse.Utilities.getUploadMarkdown(data.result); - // appends a space at the end of the inserted markdown - self.addMarkdown(markdown + " "); - self.set('isUploading', false); - } else { - bootbox.alert(I18n.t('post.errors.upload')); + if (!cancelledByTheUser) { + // make sure we have a url + if (data.result.url) { + var markdown = Discourse.Utilities.getUploadMarkdown(data.result); + // appends a space at the end of the inserted markdown + self.addMarkdown(markdown + " "); + self.set('isUploading', false); + } else { + // display the error message sent by the server + bootbox.alert(data.result.join("\n")); + } } }); @@ -358,8 +377,10 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { $uploadTarget.on('fileuploadfail', function (e, data) { // hide upload status self.set('isUploading', false); - // display an error message - Discourse.Utilities.displayErrorForUpload(data); + if (!cancelledByTheUser) { + // display an error message + Discourse.Utilities.displayErrorForUpload(data); + } }); // contenteditable div hack for getting image paste to upload working in diff --git a/app/assets/javascripts/discourse/views/discovery-categories.js.es6 b/app/assets/javascripts/discourse/views/discovery-categories.js.es6 index 8571185e21..460a154237 100644 --- a/app/assets/javascripts/discourse/views/discovery-categories.js.es6 +++ b/app/assets/javascripts/discourse/views/discovery-categories.js.es6 @@ -1,3 +1,11 @@ import UrlRefresh from 'discourse/mixins/url-refresh'; -export default Discourse.View.extend(UrlRefresh, Discourse.ScrollTop); +export default Discourse.View.extend(UrlRefresh, Discourse.ScrollTop, { + _addBodyClass: function() { + $('body').addClass('categories-list'); + }.on('didInsertElement'), + + _removeBodyClass: function() { + $('body').removeClass('categories-list'); + }.on('willDestroyElement') +}); diff --git a/app/assets/javascripts/discourse/views/dropdown-button.js.es6 b/app/assets/javascripts/discourse/views/dropdown-button.js.es6 index b10d8ad7a4..e656d6424f 100644 --- a/app/assets/javascripts/discourse/views/dropdown-button.js.es6 +++ b/app/assets/javascripts/discourse/views/dropdown-button.js.es6 @@ -31,20 +31,23 @@ export default Discourse.View.extend(StringBuffer, { buffer.push(""); buffer.push(""); diff --git a/app/assets/javascripts/discourse/views/navigation-category.js.es6 b/app/assets/javascripts/discourse/views/navigation-category.js.es6 index 76e0c1c0c3..35adfe4599 100644 --- a/app/assets/javascripts/discourse/views/navigation-category.js.es6 +++ b/app/assets/javascripts/discourse/views/navigation-category.js.es6 @@ -1,5 +1,5 @@ import AddCategoryClass from 'discourse/mixins/add-category-class'; export default Em.View.extend(AddCategoryClass, { - categoryId: Em.computed.alias('controller.category.id'), + categorySlug: Em.computed.alias('controller.category.slug') }); diff --git a/app/assets/javascripts/discourse/views/post-menu.js.es6 b/app/assets/javascripts/discourse/views/post-menu.js.es6 index 4c6b2bd0ef..6dd4ce27d0 100644 --- a/app/assets/javascripts/discourse/views/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/views/post-menu.js.es6 @@ -48,6 +48,7 @@ export default Discourse.View.extend(StringBuffer, { rerenderTriggers: [ 'post.deleted_at', + 'post.like_count', 'post.reply_count', 'post.showRepliesBelow', 'post.can_delete', @@ -56,6 +57,7 @@ export default Discourse.View.extend(StringBuffer, { 'post.topic.deleted_at', 'post.replies.length', 'post.wiki', + 'post.post_type', 'collapsed'], _collapsedByDefault: function() { @@ -327,7 +329,7 @@ export default Discourse.View.extend(StringBuffer, { '

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

    ' + '' + diff --git a/app/assets/javascripts/discourse/views/post_view.js b/app/assets/javascripts/discourse/views/post.js.es6 similarity index 89% rename from app/assets/javascripts/discourse/views/post_view.js rename to app/assets/javascripts/discourse/views/post.js.es6 index f95f12e255..879f51b676 100644 --- a/app/assets/javascripts/discourse/views/post_view.js +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -1,6 +1,6 @@ var DAY = 60 * 50 * 1000; -Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { +var PostView = Discourse.GroupedView.extend(Ember.Evented, { classNames: ['topic-post', 'clearfix'], templateName: 'post', classNameBindings: ['postTypeClass', @@ -175,11 +175,7 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { }, actions: { - /** - Toggle the replies this post is a reply to - - @method showReplyHistory - **/ + // Toggle the replies this post is a reply to toggleReplyHistory: function(post) { var replyHistory = post.get('replyHistory'), @@ -203,7 +199,7 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { } Em.run.next(function() { - Discourse.PostView.highlight(replyPostNumber); + PostView.highlight(replyPostNumber); $(window).scrollTop(self.$().position().top - offsetFromTop); }); return; @@ -267,15 +263,7 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { this._showLinkCounts(); - // Track this post Discourse.ScreenTrack.current().track(this.$().prop('id'), postNumber); - - // Highlight the post if required - if (postNumber > 1) { - Discourse.PostView.considerHighlighting(this.get('controller'), postNumber); - } - - // Add syntax highlighting Discourse.SyntaxHighlighting.apply($post); Discourse.Lightbox.apply($post); @@ -307,28 +295,4 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { }.observes('controller.searchHighlight', 'cooked') }); -Discourse.PostView.reopenClass({ - highlight: function(postNumber){ - var $contents = $('#post_' + postNumber +' .topic-body'), - origColor = $contents.data('orig-color') || $contents.css('backgroundColor'); - - $contents.data("orig-color", origColor); - $contents - .addClass('highlighted') - .stop() - .animate({ backgroundColor: origColor }, 2500, 'swing', function(){ - $contents.removeClass('highlighted'); - $contents.css({'background-color': ''}); - }); - }, - - considerHighlighting: function(controller, postNumber) { - var highlightNumber = controller.get('highlightOnInsert'); - - // If we're meant to highlight a post - if (highlightNumber === postNumber) { - controller.set('highlightOnInsert', null); - this.highlight(postNumber); - } - } -}); +export default PostView; diff --git a/app/assets/javascripts/discourse/views/topic-list-item.js.es6 b/app/assets/javascripts/discourse/views/topic-list-item.js.es6 index 960dadbe01..00f4aa7cf2 100644 --- a/app/assets/javascripts/discourse/views/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-list-item.js.es6 @@ -6,13 +6,36 @@ export default Discourse.View.extend(StringBuffer, { rawTemplate: 'list/topic_list_item.raw', classNameBindings: ['controller.checked', ':topic-list-item', - 'unboundClassNames' + 'unboundClassNames', + 'selected' ], + actions: { + select: function(){ + this.set('controller.selectedRow', this); + }, + + toggleBookmark: function(){ + var self = this; + this.get('topic').toggleBookmark().catch(function(){ + self.rerender(); + }); + self.rerender(); + } + }, + + selected: function(){ + return this.get('controller.selectedRow')===this; + }.property('controller.selectedRow'), unboundClassNames: function(){ var classes = []; var topic = this.get('topic'); + + if (topic.get('category')) { + classes.push("category-" + topic.get('category.slug')); + } + if(topic.get('hasExcerpt')){ classes.push('has-excerpt'); } diff --git a/app/assets/javascripts/discourse/views/topic.js.es6 b/app/assets/javascripts/discourse/views/topic.js.es6 index 76733c7950..bed33a452d 100644 --- a/app/assets/javascripts/discourse/views/topic.js.es6 +++ b/app/assets/javascripts/discourse/views/topic.js.es6 @@ -1,6 +1,8 @@ import AddCategoryClass from 'discourse/mixins/add-category-class'; +import { listenForViewEvent } from 'discourse/lib/app-events'; +import { categoryBadgeHTML } from 'discourse/helpers/category-link'; -export default Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { +var TopicView = Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { templateName: 'topic', topicBinding: 'controller.model', userFiltersBinding: 'controller.userFilters', @@ -13,7 +15,7 @@ export default Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { menuVisible: true, SHORT_POST: 1200, - categoryId: Em.computed.alias('topic.category.id'), + categorySlug: Em.computed.alias('topic.category.slug'), postStream: Em.computed.alias('controller.postStream'), @@ -38,7 +40,7 @@ export default Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { this.bindScrolling({name: 'topic-view'}); var self = this; - $(window).resize('resize.discourse-on-scroll', function() { + $(window).on('resize.discourse-on-scroll', function() { self.scrolled(); }); @@ -129,7 +131,7 @@ export default Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { } if (category) { - opts.catLink = Discourse.HTML.categoryBadge(category, {showParent: true}); + opts.catLink = categoryBadgeHTML(category); } else { opts.catLink = "" + I18n.t("topic.browse_all_categories") + ""; } @@ -157,3 +159,22 @@ export default Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { } }.property('topicTrackingState.messageCount') }); + +function highlight(postNumber) { + var $contents = $('#post_' + postNumber +' .topic-body'), + origColor = $contents.data('orig-color') || $contents.css('backgroundColor'); + + $contents.data("orig-color", origColor) + .addClass('highlighted') + .stop() + .animate({ backgroundColor: origColor }, 2500, 'swing', function(){ + $contents.removeClass('highlighted'); + $contents.css({'background-color': ''}); + }); +} + +listenForViewEvent(TopicView, 'post:highlight', function(postNumber) { + Ember.run.scheduleOnce('afterRender', null, highlight, postNumber); +}); + +export default TopicView; diff --git a/app/assets/javascripts/discourse/views/user-card.js.es6 b/app/assets/javascripts/discourse/views/user-card.js.es6 index 1239420297..66e62b88d5 100644 --- a/app/assets/javascripts/discourse/views/user-card.js.es6 +++ b/app/assets/javascripts/discourse/views/user-card.js.es6 @@ -11,6 +11,7 @@ export default Discourse.View.extend(CleansUp, { addBackground: function() { var url = this.get('controller.user.card_background'); + if (!this.get('allowBackgrounds')) { return; } var $this = this.$(); @@ -19,7 +20,7 @@ export default Discourse.View.extend(CleansUp, { if (Ember.isEmpty(url)) { $this.css('background-image', '').addClass('no-bg'); } else { - $this.css('background-image', "url(" + url + ")").removeClass('no-bg'); + $this.css('background-image', "url(" + Discourse.getURLWithCDN(url) + ")").removeClass('no-bg'); } }.observes('controller.user.card_background'), diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index d63f95994f..4e2bbb7c55 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -522,7 +522,7 @@ I18n.enable_verbose_localization = function(){ if (!_.isEmpty(value)) { message += ", parameters: " + JSON.stringify(value); } - window.console.log(message); + //window.console.log(message); } return t.apply(I18n, [scope, value]) + " (t" + current + ")"; }; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 5759d7a962..2e7e7f43ba 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -10,7 +10,9 @@ // // Stuff we need to load first +//= require ./discourse/lib/app-events //= require ./discourse/helpers/i18n +//= require ./discourse/helpers/fa-icon //= require ./discourse/lib/ember_compat_handlebars //= require ./discourse/lib/computed //= require ./discourse/helpers/register-unbound diff --git a/app/assets/javascripts/pagedown_custom.js b/app/assets/javascripts/pagedown_custom.js index e435ebf16f..8dec516811 100644 --- a/app/assets/javascripts/pagedown_custom.js +++ b/app/assets/javascripts/pagedown_custom.js @@ -4,7 +4,6 @@ window.PagedownCustom = { id: 'wmd-quote-post', description: I18n.t("composer.quote_post_title"), execute: function() { - // AWFUL but I can't figure out how to call a controller method from outside our app return Discourse.__container__.lookup('controller:composer').send('importQuote'); } } diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index d77de3dc56..7a9e50fdae 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -463,6 +463,7 @@ section.details { .groups { .ac-wrap { width: 100% !important; + border-color: scale-color($primary, $lightness: 75%); .item { width: 190px; margin-right: 0 !important; @@ -477,6 +478,16 @@ section.details { .btn.add { margin-top: 7px; } + .controls { + margin-top: 10px; + } + .select2-container { + width: 100%; + } + .select2-choices { + width: 100%; + border-color: scale-color($primary, $lightness: 75%); + } } // Customise area @@ -1425,3 +1436,7 @@ tr.not-activated { } } } + +.preview { + margin-top: 5px; +} diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index 751adbada0..a16cadd0af 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -100,10 +100,7 @@ } td.category a { - // suppress extra long category names in tables max-width:150px; - overflow:hidden; - text-overflow:ellipsis; } } @@ -154,18 +151,21 @@ } .list-controls { - .home { - background-color: scale-color-diff(); + .category-dropdown-menu .home { color: $primary; + margin-left: 8px; } + .badge-category { padding: 4px 10px; display: inline-block; line-height: 24px; - float: left; + } + + .category-dropdown-menu .badge-category { + width: 100%; } .category-dropdown-button { - border-left: 1px solid rgba(0,0,0,0.15); font-size: 1.143em; width: 10px; text-align: center; @@ -174,7 +174,7 @@ opacity: 0.8; } } -clear: both; + clear: both; } #list-area { @@ -223,18 +223,21 @@ ol.category-breadcrumb { background-color: $secondary; z-index: 100; + .cat { + margin-right: 20px; + } + + a.badge-category, a.badge-category-parent { + line-height: 19px; + overflow:hidden; + margin-bottom: 0; + } a.badge-category { font-size: 0.929em; font-weight: bold; float: none; - line-height: 19px; text-transform: none; - width: 100%; - min-width: 102px; - margin-right: 20px; - margin-bottom: 0; max-width:200px; - overflow:hidden; text-overflow:ellipsis; } } diff --git a/app/assets/stylesheets/common/base/code_highlighting.scss b/app/assets/stylesheets/common/base/code_highlighting.scss index 8f6dc01579..02aa19e727 100644 --- a/app/assets/stylesheets/common/base/code_highlighting.scss +++ b/app/assets/stylesheets/common/base/code_highlighting.scss @@ -8,7 +8,6 @@ github.com style (c) Vasily Polovnyov display: block; padding: 0.5em; color: #333; - background: #f8f8f8; } .hljs-comment, @@ -137,4 +136,4 @@ p > code, li > code, pre > code { background: #f8f8f8; } -// removed some unnecessary styles here \ No newline at end of file +// removed some unnecessary styles here diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss index 843309b69e..d0bd6dc38b 100644 --- a/app/assets/stylesheets/common/base/emoji.scss +++ b/app/assets/stylesheets/common/base/emoji.scss @@ -15,11 +15,12 @@ body img.emoji { margin-top: -100px; left: 50%; top: 50%; - background-color: white; + background-color: #dadada; } .emoji-page td { - border: 1px solid #eee; + border: 1px solid transparent; + background-color: white; } .emoji-page a { @@ -32,13 +33,9 @@ body img.emoji { } .emoji-table-wrapper { - min-width: 444px; + min-width: 442px; min-height: 185px; -} - -.emoji-page { - border-collapse: collapse; - margin: 3px; + background-color: white; } .emoji-modal-wrapper { @@ -52,19 +49,15 @@ body img.emoji { background-color: black; } - .emoji-modal .toolbar { margin: 0; - padding: 0; - margin-top: 10px; - margin-left: 5px; + margin-top: 8px; margin-bottom: 5px } .emoji-modal .toolbar li { display: inline; - margin: 0; - padding: 0; + padding-right: 1px; } .emoji-modal .toolbar li a { diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index d746de67e9..74e9803c1e 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -8,6 +8,7 @@ .docked & { position: fixed; + backface-visibility: hidden; /** do magic for scrolling performance **/ } .contents { @@ -17,7 +18,6 @@ .title { display: table; float: left; - height: 45px; > a { display: table-cell; vertical-align: middle; @@ -296,6 +296,7 @@ float: left; background-color: transparent; line-height: 20px; + width: 45%; margin: 5px 5px 0 5px; .badge-notification { color: scale-color($primary, $lightness: 50%); @@ -316,19 +317,23 @@ } } -.search-link .topic-statuses { - float: none; - display: inline-block; - color: scale-color($primary, $lightness: 50%); - margin: 0; - .fa { - margin: 0; +.search-link { + .badge-category-parent { + line-height: 0.8em; + } + .topic-title { + margin-right: 6px; } -} -.search-link .badge-category { - padding: 4px 6px; - margin-left: 6px; + .topic-statuses { + float: none; + display: inline-block; + color: scale-color($primary, $lightness: 50%); + margin: 0; + .fa { + margin: 0; + } + } } .highlight-strong { diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 53f911cef7..2d36cb8346 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -33,11 +33,19 @@ a { word-wrap: break-word; } } + +.cooked, #wmd-preview { + video { + max-width: 100%; + } +} // we use aside to hold expandable quotes (versus, say, static blockquotes) aside.quote { margin-top: 1em; margin-bottom: 1em; + .badge-wrapper { margin-left: 5px; } + .title { border-left: 5px solid darken(scale-color-diff(), 10%); background-color: scale-color-diff(); diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 0bb00feee9..1afcedb7fe 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -1,10 +1,16 @@ #topic-title { - .title-wrapper {float: left; width: 90%;} + .title-wrapper { + float: left; + width: 90%; + .btn-small { + margin: 0 6px 0 0; + } + } a.badge-category { margin-top: 5px; - } + } a.edit-topic i { font-size: 0.8em; } } diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 004c09f5db..f1495540f7 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -24,6 +24,16 @@ } } +.public-user-fields { + .user-field-name { + font-weight: bold; + } +} + +.collapsed-info .public-user-fields { + display: none; +} + .notification-buttons { margin: 10px 0; text-align: right; diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index 7549e5f563..bff9117196 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -15,26 +15,60 @@ // Category badge // -------------------------------------------------- -.badge-category, .badge-category-parent { +.badge-wrapper span { font-size: 0.857em; font-weight: bold; white-space: nowrap; display: inline-block; line-height: 1; + position: relative; } + .badge-wrapper { white-space: nowrap; + position: relative; + display: inline-block; } .badge-category { - padding: 6px; - color: $secondary; - &[href] { - color: $secondary; + padding: 6px 4px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +.badge-wrapper > .badge-category { + // ie9? + width: 100%; + width: calc(100% - 10px) +} + +.d-header li.category .badge-wrapper { + max-width: calc(100% - 30px) +} + +h3 .badge-category { + padding-left: 3px; +} + +h3 .badge-wrapper { + padding-left: 4px; +} + + +header .title-wrapper { + + .badge-category-bg, .badge-category { + vertical-align: middle; + } + + .badge-category-bg { + padding-top:0; + padding-bottom:0; } } -.badge-category-parent { +.badge-category-parent-bg, .badge-category-bg { padding: 6px 2px; width: 2px; .category-name { @@ -47,7 +81,7 @@ .d-dropdown .badge-category { &.restricted { - div { + span { display: inline-block; margin: 0; } @@ -55,6 +89,68 @@ h1 a.badge-category div {vertical-align: top;} +.category-breadcrumb li > .badge-category { + float: left; +} + +.user-preferences .autocomplete .badge-wrapper .badge-category { + margin: 2px; + font-weight: normal; +} + +.user-preferences .autocomplete .selected .badge-wrapper .badge-category { + font-weight: bold; +} + +.ac-wrap { + .badge-wrapper span { + padding-top: 3px; + padding-bottom: 0; + height: 20px; + max-width: 200px; + } +} + +// specific styles for badge categories + +.bar .badge-category { + color: $primary !important; +} + +header .title-wrapper .bar .badge-category { + color: $header-primary !important; +} + + +.category-breadcrumb li.bar > .badge-category { + background: dark-light-diff($primary, $secondary, 95%, -65%) !important; + &:not(.home):first-child { + border-left-width: 5px; + border-left-style: solid; + } +} + +.category-dropdown-menu .cat .badge-wrapper.box { + width: 110%; +} +.badge-wrapper.box { + .badge-category-bg { + position: absolute; + padding: 0; + width: 100%; + height: 100%; + } + + > .badge-category { + width: 100%; + width: calc(100% - 2px); + } +} + +.title-wrapper .badge-wrapper.box .badge-category { + vertical-align: middle; +} + // Notification badge // -------------------------------------------------- diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 5c9032b742..ca0af4639d 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -81,6 +81,9 @@ margin-left: 10px; } +// hide cancel upload link on IE9 (not supported) +.ie9 #cancel-file-upload { display: none; } + #reply-control { .toggle-preview, #draft-status, #file-uploading { position: absolute; diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss index e35b97994c..2e9ee7e841 100644 --- a/app/assets/stylesheets/desktop/discourse.scss +++ b/app/assets/stylesheets/desktop/discourse.scss @@ -136,6 +136,10 @@ body { margin-bottom: 10px; } + .not-found-topic { + a[href] { margin-right: 10px; line-height: 2;} + } + .page-not-found-topics .span8 { line-height: 1.5em; margin-right: 20px; diff --git a/app/assets/stylesheets/desktop/history.scss b/app/assets/stylesheets/desktop/history.scss index efb72c4cd9..e547294ae1 100644 --- a/app/assets/stylesheets/desktop/history.scss +++ b/app/assets/stylesheets/desktop/history.scss @@ -22,8 +22,6 @@ background-color: scale-color-diff(); padding: 5px; margin-top: 10px; - line-height: 2em; - height: 30px; } #revisions { word-wrap: break-word; diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index fe40b00de1..80aaa83b4b 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -25,16 +25,12 @@ &.category-dropdown-button { height: 24px; - padding: 3px 9px; + padding: 3px 9px 3px 0; i { height: 20px; } } } - .badge-category { - font-weight: normal; - } - } // Base list @@ -42,10 +38,10 @@ .topic-list { margin: 0 0 10px; - .fa-thumb-tack {color: $primary;} - .fa-thumb-tack.unpinned {color: $primary;} + .fa-thumb-tack { color: scale-color($primary, $lightness: 50%); } + .fa-thumb-tack.unpinned { color: scale-color($primary, $lightness: 50%); } a.title {color: $primary;} - a.title:visited:not(.badge-notification), .fa-bookmark {color: scale-color($primary, $lightness: 35%);} + a.title:visited:not(.badge-notification), .fa-bookmark { color: scale-color($primary, $lightness: 50%); } th, td { padding: 12px 5px; @@ -89,9 +85,6 @@ padding-left: 5px; } } - .main-link { - width: 495px; - } .badge-notification { position: relative; @@ -323,6 +316,15 @@ button.dismiss-read { @media all and (max-width : 850px) { + // add some left padding to topics otherwise everything is 100% flush + // with left edge in portrait tablet, which looks awful + #topic-title { + padding-left: 10px; + } + .container.posts { + padding-left: 10px; + } + .nav-pills { > li > a { font-size: 1em; @@ -351,9 +353,6 @@ and (max-width : 850px) { .categories td.category { padding-left: 10px; } - td:first-of-type { - padding: 0; - } th:first-of-type { padding: 12px 5px; } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index aef34a1431..576d2f2d8f 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -254,7 +254,6 @@ nav.post-controls { opacity: 0; } } - &.bottom .arrow { float: right; } &.bottom { margin-top: -11px; .row { @@ -273,7 +272,7 @@ nav.post-controls { } .post-date { color: scale-color($primary, $lightness: 50%); } - .fa-arrow-up { margin-left: 5px; } + .fa-arrow-up, .fa-arrow-down { margin-left: 5px; } .row { border-top: 1px solid darken(scale-color-diff(), 10%); } @@ -473,10 +472,24 @@ a.star { } } table { - box-shadow: none; - border-radius: 0; + table-layout: fixed; margin-top: 10px; } + .topics { + padding-bottom: 15px; + } + // this forces category to take less space in suggested topics + // as the poster list is not present at all there. + th.category { + width: 150px; + } +} + +#suggested-topics .topic-statuses .topic-status { + padding: 0; + i { + font-size:15px; + } } span.post-count { @@ -514,10 +527,10 @@ video { .extra-info-wrapper { overflow: hidden; -.star, .badge-wrapper, i, .topic-link:not(.loading) { - -webkit-animation: fadein .7s; - animation: fadein .7s; -} + .star, .badge-wrapper, i, .topic-link:not(.loading) { + -webkit-animation: fadein .7s; + animation: fadein .7s; + } .topic-statuses { i { color: $header_primary; } diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index ddea17cbe2..e17e9fa2e3 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -41,8 +41,7 @@ line-height: 1.2em; overflow: hidden; a {color: $primary;} - - + margin-bottom: 5px; } @@ -204,23 +203,12 @@ a:hover.reply-new { bottom: 0; width: 0; border-right: 1px solid scale-color-diff(); - background-color: dark-light-diff($success, $secondary, 60%, -35%); + background-color: desaturate(dark-light-diff($success, $secondary, 60%, -35%), 30%); transition: width .75s; } } -#suggested-topics .topic-statuses .topic-status { - padding: 0; - i { - font-size:15px; - } -} - -#suggested-topics .topics { - padding-bottom: 15px; -} - .heatmap-high {color: #fe7a15 !important;} .heatmap-med {color: #cf7721 !important;} .heatmap-low {color: #9b764f !important;} diff --git a/app/assets/stylesheets/desktop/upload.scss b/app/assets/stylesheets/desktop/upload.scss index 80d0667cac..431130be68 100644 --- a/app/assets/stylesheets/desktop/upload.scss +++ b/app/assets/stylesheets/desktop/upload.scss @@ -36,4 +36,7 @@ .image-upload-controls { padding: 10px; + label.btn { + padding: 7px 10px 5px 10px; + } } diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 41e6923ab2..cc3fe23158 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -26,15 +26,6 @@ width: 500px; } - .autocomplete .badge-category { - margin: 2px; - font-weight: normal; - } - - .autocomplete .badge-category.selected { - font-weight: bold; - } - textarea { width: 530px; height: 100px; @@ -402,6 +393,9 @@ .user-stream { + .category { + margin-left: 3px; + } .excerpt { margin: 5px 0; font-size: 0.929em; diff --git a/app/assets/stylesheets/mobile/alert.scss b/app/assets/stylesheets/mobile/alert.scss index ec17f33ab5..dbd1770bec 100644 --- a/app/assets/stylesheets/mobile/alert.scss +++ b/app/assets/stylesheets/mobile/alert.scss @@ -3,10 +3,8 @@ } // there are (n) new or updated topics, click to show -.topic-list { - .alert { +.alert.alert-info { margin: 0; padding: 15px; - font-size: 120%; - } + font-size: 1.1em; } diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss index abefe68402..a8b293fb76 100644 --- a/app/assets/stylesheets/mobile/topic-list.scss +++ b/app/assets/stylesheets/mobile/topic-list.scss @@ -63,8 +63,6 @@ } .category a { max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; } .num .fa { color: scale-color($primary, $lightness: 50%); @@ -144,8 +142,7 @@ tr.category-topic-link:nth-of-type(odd) { margin-bottom: 10px; th .badge-category { - float: left; - margin: 1px 4px 0 0; + margin: 0; font-size: 110%; } diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index 457aac87f8..56111455bd 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -384,12 +384,36 @@ iframe { } #selected-posts { - padding-left: 20px; + float: left; + width: 97%; + padding-left: 3%; + background-color: dark-light-diff($tertiary, $secondary, 90%, -40%); .btn { margin-bottom: 10px; + color: $secondary; + background: scale-color($tertiary, $lightness: 50%); + clear: both; + } + p { + clear: both; } } +// hide the full set of selection buttons on mobile +.select-posts button { display: none; } + +// unhide the simple "select just this post" button +button.select-post { + display: inline-block; + position: absolute; + z-index: 401; // 400 is the reply-to tab + left: 200px; + background-color: scale-color($tertiary, $lightness: 50%); + color: $secondary; + padding: 5px; +} + + .post-select { float: right; margin-right: 20px; @@ -476,3 +500,4 @@ span.highlighted { .read-state { display: none; } + diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index 4bcdc90685..7bddfa1792 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -94,15 +94,6 @@ input.category-group { } - .autocomplete .badge-category { - margin: 2px; - font-weight: normal; - } - - .autocomplete .badge-category.selected { - font-weight: bold; - } - textarea { height: 100px; } diff --git a/app/controllers/admin/backups_controller.rb b/app/controllers/admin/backups_controller.rb index 3a3ae1b1c2..a80e1b924d 100644 --- a/app/controllers/admin/backups_controller.rb +++ b/app/controllers/admin/backups_controller.rb @@ -137,7 +137,7 @@ class Admin::BackupsController < Admin::AdminController private def has_enough_space_on_disk?(size) - `df -Pk . | awk 'NR==2 {print $4 * 1024;}'`.to_i > size + `df -Pk #{Rails.root}/public/backups | awk 'NR==2 {print $4 * 1024;}'`.to_i > size end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index dd9e5f720d..1b62db41e5 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -21,8 +21,12 @@ class Admin::GroupsController < Admin::AdminController def create group = Group.new + group.name = (params[:name] || '').strip + group.alias_level = params[:alias_level].to_i if params[:alias_level].present? group.visible = params[:visible] == "true" + group.automatic_membership_email_domains = params[:automatic_membership_email_domains] + group.automatic_membership_retroactive = params[:automatic_membership_retroactive] == "true" if group.save render_serialized(group, BasicGroupSerializer) @@ -32,22 +36,24 @@ class Admin::GroupsController < Admin::AdminController end def update - group = Group.find(params[:id].to_i) + group = Group.find(params[:id]) - group.alias_level = params[:alias_level].to_i if params[:alias_level].present? - group.visible = params[:visible] == "true" # group rename is ignored for automatic groups group.name = params[:name] if params[:name] && !group.automatic + group.alias_level = params[:alias_level].to_i if params[:alias_level].present? + group.visible = params[:visible] == "true" + group.automatic_membership_email_domains = params[:automatic_membership_email_domains] + group.automatic_membership_retroactive = params[:automatic_membership_retroactive] == "true" if group.save - render json: success_json + render_serialized(group, BasicGroupSerializer) else render_json_error group end end def destroy - group = Group.find(params[:id].to_i) + group = Group.find(params[:id]) if group.automatic can_not_modify_automatic @@ -63,7 +69,7 @@ class Admin::GroupsController < Admin::AdminController end def add_members - group = Group.find(params.require(:group_id).to_i) + group = Group.find(params.require(:id)) usernames = params.require(:usernames) return can_not_modify_automatic if group.automatic @@ -82,7 +88,7 @@ class Admin::GroupsController < Admin::AdminController end def remove_member - group = Group.find(params.require(:group_id).to_i) + group = Group.find(params.require(:id)) user_id = params.require(:user_id).to_i return can_not_modify_automatic if group.automatic diff --git a/app/controllers/admin/user_fields_controller.rb b/app/controllers/admin/user_fields_controller.rb index 56ab3889de..e5e8c9c3a9 100644 --- a/app/controllers/admin/user_fields_controller.rb +++ b/app/controllers/admin/user_fields_controller.rb @@ -1,7 +1,7 @@ class Admin::UserFieldsController < Admin::AdminController def self.columns - [:name, :field_type, :editable, :description, :required] + [:name, :field_type, :editable, :description, :required, :show_on_profile] end def create diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index e62999fe01..c0f2469524 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -53,6 +53,7 @@ class Admin::UsersController < Admin::AdminController @user.suspended_at = DateTime.now @user.save! StaffActionLogger.new(current_user).log_user_suspend(@user, params[:reason]) + MessageBus.publish "/logout", @user.id, user_ids: [@user.id] render nothing: true end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6d10f99780..ff805687b4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -126,7 +126,7 @@ class ApplicationController < ActionController::Base # from the above rescue_from blocks will fail because that isn't valid json. render status: error, layout: false, text: (error == 404) ? build_not_found_page(error) : message else - render text: build_not_found_page(error, include_ember ? 'application' : 'no_js') + render text: build_not_found_page(error, include_ember ? 'application' : 'no_ember') end end @@ -155,7 +155,7 @@ class ApplicationController < ActionController::Base # If we are rendering HTML, preload the session data def preload_json # We don't preload JSON on xhr or JSON request - return if request.xhr? + return if request.xhr? || request.format.json? preload_anonymous_data diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index a97f7ff770..c93ad37ab0 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -36,7 +36,7 @@ class CategoriesController < ApplicationController guardian.ensure_can_create!(Category) file = params[:file] || params[:files].first - upload = Upload.create_for(current_user.id, file.tempfile, file.original_filename, File.size(file.tempfile)) + upload = Upload.create_for(current_user.id, file.tempfile, file.original_filename, file.tempfile.size) if upload.errors.blank? render json: { url: upload.url, width: upload.width, height: upload.height } else diff --git a/app/controllers/email_controller.rb b/app/controllers/email_controller.rb index 7fe28798df..01d07426cd 100644 --- a/app/controllers/email_controller.rb +++ b/app/controllers/email_controller.rb @@ -1,6 +1,6 @@ class EmailController < ApplicationController skip_before_filter :check_xhr - layout 'no_js' + layout 'no_ember' before_filter :ensure_logged_in, only: :preferences_redirect skip_before_filter :redirect_to_login_if_required diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb index a8f875ede6..353ddcd9d3 100644 --- a/app/controllers/export_csv_controller.rb +++ b/app/controllers/export_csv_controller.rb @@ -15,7 +15,7 @@ class ExportCsvController < ApplicationController def show params.require(:id) filename = params.fetch(:id) - export_id = filename.split('-')[2].split('.')[0] + export_id = filename.split('-')[-1].split('.')[0] export_initiated_by_user_id = 0 export_initiated_by_user_id = UserExport.where(id: export_id)[0].user_id unless UserExport.where(id: export_id).empty? export_csv_path = UserExport.get_download_path(filename) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index f6d7af0b5c..9a51833a62 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -35,6 +35,38 @@ class GroupsController < ApplicationController } end + def add_members + guardian.ensure_can_edit!(the_group) + + added_users = [] + usernames = params.require(:usernames) + usernames.split(",").each do |username| + if user = User.find_by_username(username) + unless the_group.users.include?(user) + the_group.add(user) + added_users << user + end + end + end + + # always succeeds, even if bogus usernames were provided + render_serialized(added_users, GroupUserSerializer) + end + + def remove_member + guardian.ensure_can_edit!(the_group) + + removed_users = [] + username = params.require(:username) + if user = User.find_by_username(username) + the_group.remove(user) + removed_users << user + end + + # always succeeds, even if user was not a member + render_serialized(removed_users, GroupUserSerializer) + end + private def find_group(param_name) @@ -44,4 +76,8 @@ class GroupsController < ApplicationController group end + def the_group + @the_group ||= find_group(:group_id) + end + end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 3958a7db85..c5289ce431 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -163,7 +163,7 @@ class InvitesController < ApplicationController def ensure_new_registrations_allowed unless SiteSetting.allow_new_registrations flash[:error] = I18n.t('login.new_registrations_disabled') - render layout: 'no_js' + render layout: 'no_ember' false end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 2d0b936d01..34a5a8c353 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -5,7 +5,7 @@ require_dependency 'distributed_memoizer' class PostsController < ApplicationController # Need to be logged in for all actions here - before_filter :ensure_logged_in, except: [:show, :replies, :by_number, :short_link, :reply_history, :revisions, :latest_revision, :expand_embed, :markdown, :raw, :cooked] + before_filter :ensure_logged_in, except: [:show, :replies, :by_number, :short_link, :reply_history, :revisions, :latest_revision, :expand_embed, :markdown_id, :markdown_num, :cooked, :latest] skip_before_filter :check_xhr, only: [:markdown_id, :markdown_num, :short_link] @@ -25,6 +25,33 @@ class PostsController < ApplicationController end end + def latest + params.permit(:before) + last_post_id = params[:before].to_i + last_post_id = Post.last.id if last_post_id <= 0 + + # last 50 post IDs only, to avoid counting deleted posts in security check + posts = Post.order(created_at: :desc) + .where('posts.id <= ?', last_post_id) + .where('posts.id > ?', last_post_id - 50) + .includes(topic: :category) + .includes(:user) + .limit(50) + # Remove posts the user doesn't have permission to see + # This isn't leaking any information we weren't already through the post ID numbers + posts = posts.reject { |post| !guardian.can_see?(post) } + + counts = PostAction.counts_for(posts, current_user) + + render_json_dump(serialize_data(posts, + PostSerializer, + scope: guardian, + root: 'latest_posts', + add_raw: true, + all_post_actions: counts) + ) + end + def cooked post = find_post_from_params render json: {cooked: post.cooked} @@ -85,7 +112,8 @@ class PostsController < ApplicationController [false, MultiJson.dump(errors: post_creator.errors.full_messages)] else - DiscourseEvent.trigger(:topic_saved, post.topic, params, current_user) + DiscourseEvent.trigger(:topic_created, post.topic, params, current_user) unless params[:topic_id] + DiscourseEvent.trigger(:post_created, post, params, current_user) post_serializer = PostSerializer.new(post, scope: guardian, root: false) post_serializer.draft_sequence = DraftSequence.current(current_user, post.topic.draft_key) [true, MultiJson.dump(post_serializer)] @@ -382,7 +410,6 @@ class PostsController < ApplicationController permitted = [ :raw, :topic_id, - :title, :archetype, :category, :target_usernames, @@ -415,8 +442,15 @@ class PostsController < ApplicationController result[:is_warning] = (params[:is_warning] == "true") end - # Enable plugins to whitelist additional parameters they might need - DiscourseEvent.trigger(:permit_post_params, result, params) + PostRevisor.tracked_topic_fields.keys.each do |f| + params.permit(f => []) + result[f] = params[f] if params.has_key?(f) + end + + # Stuff we can use in spam prevention plugins + result[:ip_address] = request.remote_ip + result[:user_agent] = request.user_agent + result[:referrer] = request.env["HTTP_REFERER"] result end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 66a8c99512..12c736d1d2 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -72,6 +72,17 @@ class SessionController < ApplicationController else log_on_user user end + + # If it's not a relative URL check the host + if return_path !~ /^\/[^\/]/ + begin + uri = URI(return_path) + return_path = "/" unless uri.host == Discourse.current_hostname + rescue + return_path = "/" + end + end + redirect_to return_path else render text: I18n.t("sso.not_found"), status: 500 diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 6ca90f6a2a..721f0373c7 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -55,9 +55,11 @@ class TopicsController < ApplicationController begin @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts) rescue Discourse::NotFound - topic = Topic.find_by(slug: params[:id].downcase) if params[:id] - raise Discourse::NotFound unless topic - redirect_to_correct_topic(topic, opts[:post_number]) && return + if params[:id] + topic = Topic.find_by(slug: params[:id].downcase) + return redirect_to_correct_topic(topic, opts[:post_number]) if topic + end + raise Discourse::NotFound end page = params[:page].to_i @@ -126,18 +128,19 @@ class TopicsController < ApplicationController guardian.ensure_can_edit!(topic) changes = {} - changes[:title] = params[:title] if params[:title] && topic.title != params[:title] - changes[:category_id] = params[:category_id] if params[:category_id] && topic.category_id != params[:category_id].to_i + PostRevisor.tracked_topic_fields.keys.each do |f| + changes[f] = params[f] if params.has_key?(f) + end + + changes.delete(:title) if topic.title == changes[:title] + changes.delete(:category_id) if (changes[:category_id].blank? or topic.category_id == changes[:category_id].to_i) success = true - if changes.length > 0 first_post = topic.ordered_posts.first success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false) end - DiscourseEvent.trigger(:topic_saved, topic, params, current_user) - # this is used to return the title to the client as it may have been changed by "TextCleaner" success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic) end @@ -149,8 +152,9 @@ class TopicsController < ApplicationController [:title, :raw].each { |key| check_length_of(key, params[key]) } # Only suggest similar topics if the site has a minimum amount of topics present. - topics = Topic.similar_to(title, raw, current_user).to_a if Topic.count_exceeds_minimum? + return render json: [] unless Topic.count_exceeds_minimum? + topics = Topic.similar_to(title, raw, current_user).to_a render_serialized(topics, BasicTopicSerializer) end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index a8f1dad6f6..2b0545dfac 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -4,17 +4,17 @@ class UploadsController < ApplicationController def create file = params[:file] || params[:files].first - - filesize = File.size(file.tempfile) + filesize = file.tempfile.size upload = Upload.create_for(current_user.id, file.tempfile, file.original_filename, filesize, { content_type: file.content_type }) - if current_user.admin? + if upload.errors.empty? && current_user.admin? retain_hours = params[:retain_hours].to_i - if retain_hours > 0 - upload.update_columns(retain_hours: retain_hours) - end + upload.update_columns(retain_hours: retain_hours) if retain_hours > 0 end + # HACK FOR IE9 to prevent the "download dialog" + response.headers["Content-Type"] = "text/plain" if request.user_agent =~ /MSIE 9/ + if upload.errors.empty? render_serialized(upload, UploadSerializer, root: false) else diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb index 13ff84db8f..919be6db10 100644 --- a/app/controllers/user_avatars_controller.rb +++ b/app/controllers/user_avatars_controller.rb @@ -13,7 +13,7 @@ class UserAvatarsController < ApplicationController user.create_user_avatar(user_id: user.id) unless user.user_avatar user.user_avatar.update_gravatar! - render json: {upload_id: user.user_avatar.gravatar_upload_id} + render json: { upload_id: user.user_avatar.gravatar_upload_id } else raise Discourse::NotFound end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index e710eda62b..8c9bf69c57 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -48,7 +48,7 @@ class Users::OmniauthCallbacksController < ApplicationController def failure flash[:error] = I18n.t("login.omniauth_error") - render layout: 'no_js' + render layout: 'no_ember' end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7fd78ab6cf..a44008d11d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -88,7 +88,8 @@ class UsersController < ApplicationController user = fetch_user_from_params guardian.ensure_can_edit_username!(user) - result = user.change_username(params[:new_username]) + # TODO proper error surfacing (result is a Model#save call) + result = user.change_username(params[:new_username], current_user) raise Discourse::InvalidParameters.new(:new_username) unless result render json: { @@ -212,20 +213,19 @@ class UsersController < ApplicationController # Handle custom fields user_fields = UserField.all if user_fields.present? - if params[:user_fields].blank? && UserField.where(required: true).exists? - return fail_with("login.missing_user_field") - else - fields = user.custom_fields - user_fields.each do |f| - field_val = params[:user_fields][f.id.to_s] - if field_val.blank? - return fail_with("login.missing_user_field") if f.required? - else - fields["user_field_#{f.id}"] = field_val - end + field_params = params[:user_fields] || {} + fields = user.custom_fields + + user_fields.each do |f| + field_val = field_params[f.id.to_s] + if field_val.blank? + return fail_with("login.missing_user_field") if f.required? + else + fields["user_field_#{f.id}"] = field_val end - user.custom_fields = fields end + + user.custom_fields = fields end authentication = UserAuthenticator.new(user, session) @@ -312,7 +312,7 @@ class UsersController < ApplicationController end end end - render layout: 'no_js' + render layout: 'no_ember' end def logon_after_password_reset @@ -363,18 +363,18 @@ class UsersController < ApplicationController else flash[:error] = I18n.t('change_email.error') end - render layout: 'no_js' + render layout: 'no_ember' end def account_created @message = session['user_created_message'] expires_now - render layout: 'no_js' + render layout: 'no_ember' end def activate_account expires_now - render layout: 'no_js' + render layout: 'no_ember' end def perform_account_activation @@ -392,7 +392,7 @@ class UsersController < ApplicationController else flash[:error] = I18n.t('activation.already_done') end - render layout: 'no_js' + render layout: 'no_ember' end def send_activation_email @@ -440,6 +440,9 @@ class UsersController < ApplicationController file = params[:file] || params[:files].first + # HACK FOR IE9 to prevent the "download dialog" + response.headers["Content-Type"] = "text/plain" if request.user_agent =~ /MSIE 9/ + begin image = build_user_image_from(file) rescue Discourse::InvalidParameters diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7cf3271f3a..9bf4db32db 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,7 @@ require_dependency 'unread' require_dependency 'age_words' require_dependency 'configurable_urls' require_dependency 'mobile_detection' +require_dependency 'category_badge' module ApplicationHelper include CurrentUser @@ -153,5 +154,8 @@ module ApplicationHelper controller.class.name.split("::").first == "Admin" || session[:disable_customization] end + def category_badge(category, opts=nil) + CategoryBadge.html_for(category, opts).html_safe + end end diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb index cb662043d5..77dea02d7a 100644 --- a/app/helpers/user_notifications_helper.rb +++ b/app/helpers/user_notifications_helper.rb @@ -58,29 +58,4 @@ module UserNotificationsHelper PrettyText.format_for_email(html).html_safe end - def email_category(category, opts=nil) - opts = opts || {} - - # If there is no category, bail - return "" if category.blank? - - # By default hide uncategorized - return "" if category.uncategorized? && !opts[:show_uncategorized] - - result = "" - - category_url = "#{Discourse.base_url}#{category.url}" - - if opts[:only_stripe] - result << " " - result << "#{category.name}" - else - if category.parent_category.present? - result << " " - end - result << "#{category.name}" - end - - result.html_safe - end end diff --git a/app/jobs/regular/automatic_group_membership.rb b/app/jobs/regular/automatic_group_membership.rb new file mode 100644 index 0000000000..0b4488371a --- /dev/null +++ b/app/jobs/regular/automatic_group_membership.rb @@ -0,0 +1,23 @@ +module Jobs + + class AutomaticGroupMembership < Jobs::Base + + def execute(args) + group_id = args[:group_id] + + raise Discourse::InvalidParameters.new(:group_id) if group_id.blank? + + group = Group.find(group_id) + + return unless group.automatic_membership_retroactive + + domains = group.automatic_membership_email_domains.gsub('.', '\.') + + User.where("email ~* '@(#{domains})$'").find_each do |user| + group.add(user) rescue ActiveRecord::RecordNotUnique + end + end + + end + +end diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 82cbc1b70d..913c7593d0 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -4,6 +4,8 @@ require_dependency 'system_message' module Jobs class ExportCsvFile < Jobs::Base + include ActionView::Helpers::NumberHelper + HEADER_ATTRS_FOR = {} HEADER_ATTRS_FOR['user_archive'] = ['topic_title','category','sub_category','is_pm','post','like_count','reply_count','url','created_at'] HEADER_ATTRS_FOR['user_list'] = ['id','name','username','email','title','created_at','trust_level','active','admin','moderator','ip_address'] @@ -260,8 +262,12 @@ module Jobs def set_file_path - file_name_prefix = @file_name.split('_').join('-') - @file = UserExport.create(export_type: file_name_prefix, user_id: @current_user.id) + if @entity == "user_archive" + file_name_prefix = "#{@file_name.split('_').join('-')}-#{current_user.username}-#{Time.now.strftime("%y%m%d-%H%M%S")}" + else + file_name_prefix = "#{@file_name.split('_').join('-')}-#{Time.now.strftime("%y%m%d-%H%M%S")}" + end + @file = UserExport.create(file_name: file_name_prefix, user_id: @current_user.id) @file_name = "#{file_name_prefix}-#{@file.id}.csv" # ensure directory exists @@ -278,13 +284,13 @@ module Jobs end end # compress CSV file - `gzip --best #{File.expand_path("#{UserExport.base_directory}/#{@file_name}", __FILE__)}` + `gzip -5 #{File.expand_path("#{UserExport.base_directory}/#{@file_name}", __FILE__)}` end def notify_user if @current_user if @file_name != "" && File.exists?("#{UserExport.base_directory}/#{@file_name}.gz") - SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/export_csv/#{@file_name}.gz", file_name: "#{@file_name}.gz") + SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/export_csv/#{@file_name}.gz", file_name: "#{@file_name}.gz", file_size: number_to_human_size(File.size("#{UserExport.base_directory}/#{@file_name}.gz"))) else SystemMessage.create_from_system_user(@current_user, :csv_export_failed) end diff --git a/app/jobs/regular/resize_emoji.rb b/app/jobs/regular/resize_emoji.rb new file mode 100644 index 0000000000..272158e17c --- /dev/null +++ b/app/jobs/regular/resize_emoji.rb @@ -0,0 +1,14 @@ +module Jobs + + class ResizeEmoji < Jobs::Base + + def execute(args) + path = args[:path] + return unless File.exists?(path) + + # make sure emoji aren't too big + OptimizedImage.resize(path, path, 60, 60, true) + end + end + +end diff --git a/app/jobs/scheduled/enqueue_digest_emails.rb b/app/jobs/scheduled/enqueue_digest_emails.rb index 9fc5669980..0e373a1496 100644 --- a/app/jobs/scheduled/enqueue_digest_emails.rb +++ b/app/jobs/scheduled/enqueue_digest_emails.rb @@ -5,8 +5,10 @@ module Jobs every 6.hours def execute(args) - target_user_ids.each do |user_id| - Jobs.enqueue(:user_email, type: :digest, user_id: user_id) + unless SiteSetting.disable_digest_emails? + target_user_ids.each do |user_id| + Jobs.enqueue(:user_email, type: :digest, user_id: user_id) + end end end @@ -16,8 +18,8 @@ module Jobs .where(email_digests: true, active: true) .not_suspended .where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)") - .where("(COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)) OR - email_always") + .where("(COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)) AND + COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})") # If the site requires approval, make sure the user is approved if SiteSetting.must_approve_users? diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 736e5d64d5..6e5f600609 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -3,6 +3,7 @@ require_dependency 'email/message_builder' require_dependency 'age_words' class UserNotifications < ActionMailer::Base + helper :application default charset: 'UTF-8' include Email::BuildEmailHelper diff --git a/app/models/about.rb b/app/models/about.rb index 83dea16ec1..ad77c59150 100644 --- a/app/models/about.rb +++ b/app/models/about.rb @@ -40,12 +40,16 @@ class About post_count: Post.count, user_count: User.count, topics_7_days: Topic.listable_topics.where('created_at > ?', 7.days.ago).count, + topics_30_days: Topic.listable_topics.where('created_at > ?', 30.days.ago).count, posts_7_days: Post.where('created_at > ?', 7.days.ago).count, + posts_30_days: Post.where('created_at > ?', 30.days.ago).count, users_7_days: User.where('created_at > ?', 7.days.ago).count, + users_30_days: User.where('created_at > ?', 30.days.ago).count, + active_users_7_days: User.where('last_seen_at > ?', 7.days.ago).count, + active_users_30_days: User.where('last_seen_at > ?', 30.days.ago).count, like_count: UserAction.where(action_type: UserAction::LIKE).count, - likes_7_days: UserAction.where(action_type: UserAction::LIKE) - .where("created_at > ?", 7.days.ago) - .count + likes_7_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 7.days.ago).count, + likes_30_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 30.days.ago).count } end diff --git a/app/models/category_style_setting.rb b/app/models/category_style_setting.rb new file mode 100644 index 0000000000..d94475f2ba --- /dev/null +++ b/app/models/category_style_setting.rb @@ -0,0 +1,19 @@ +# TODO all enums should probably move out of models +# TODO we should be able to do this kind of stuff without a backing class +require_dependency 'enum_site_setting' + +class CategoryStyleSetting < EnumSiteSetting + + VALUES = ["bar", "box"] + + def self.valid_value?(val) + VALUES.include?(val) + end + + def self.values + VALUES.map do |l| + {name: l, value: l} + end + end + +end diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index cfe0db5904..7b68761902 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -78,17 +78,6 @@ module HasCustomFields !@custom_fields || @custom_fields_orig == @custom_fields end - protected - - def refresh_custom_fields_from_db - target = Hash.new - _custom_fields.pluck(:name,:value).each do |key, value| - self.class.append_custom_field(target, key, value) - end - @custom_fields_orig = target - @custom_fields = @custom_fields_orig.dup - end - def save_custom_fields if !custom_fields_clean? dup = @custom_fields.dup @@ -117,7 +106,7 @@ module HasCustomFields array_fields.each do |field_name, fields| if fields.length == dup[field_name].length && fields.map{|f| f.value} == dup[field_name] - dup.delete(f.name) + dup.delete(field_name) else fields.each{|f| f.destroy } end @@ -134,4 +123,16 @@ module HasCustomFields refresh_custom_fields_from_db end end + + protected + + def refresh_custom_fields_from_db + target = Hash.new + _custom_fields.pluck(:name,:value).each do |key, value| + self.class.append_custom_field(target, key, value) + end + @custom_fields_orig = target + @custom_fields = @custom_fields_orig.dup + end + end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index b66ea4e2b1..92af895200 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -125,17 +125,17 @@ class DiscourseSingleSignOn < SingleSignOn user.name = User.suggest_name(name || username || email) end - if SiteSetting.sso_overrides_avatar && ( - avatar_force_update == "true" || - avatar_force_update.to_i != 0 || + if SiteSetting.sso_overrides_avatar && avatar_url.present? && ( + avatar_force_update || sso_record.external_avatar_url != avatar_url) + begin - tempfile = FileHelper.download(avatar_url, 1.megabyte, "sso-avatar", true) + tempfile = FileHelper.download(avatar_url, SiteSetting.max_image_size_kb.kilobytes, "sso-avatar", true) ext = FastImage.type(tempfile).to_s tempfile.rewind - upload = Upload.create_for(user.id, tempfile, "external-avatar." + ext, File.size(tempfile.path), { origin: avatar_url }) + upload = Upload.create_for(user.id, tempfile, "external-avatar." + ext, tempfile.size, { origin: avatar_url }) user.uploaded_avatar_id = upload.id unless user.user_avatar diff --git a/app/models/emoji.rb b/app/models/emoji.rb index 8c787e5744..b283e36a40 100644 --- a/app/models/emoji.rb +++ b/app/models/emoji.rb @@ -56,6 +56,8 @@ class Emoji File.open(path, "wb") { |f| f << file.tempfile.read } # clear the cache Emoji.clear_cache + # launch resize job + Jobs.enqueue(:resize_emoji, path: path) # return created emoji Emoji.custom.detect { |e| e.name == name } end @@ -65,7 +67,7 @@ class Emoji end def self.db_file - "lib/emoji/db.json" + "#{Rails.root}/lib/emoji/db.json" end def self.load_standard diff --git a/app/models/group.rb b/app/models/group.rb index 1d9ba3721b..33ed718aed 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -7,7 +7,11 @@ class Group < ActiveRecord::Base has_many :categories, through: :category_groups has_many :users, through: :group_users + has_many :group_managers, dependent: :destroy + has_many :managers, through: :group_managers + after_save :destroy_deletions + after_save :automatic_group_membership validate :name_format_validator validates_uniqueness_of :name, case_sensitive: false @@ -277,6 +281,10 @@ class Group < ActiveRecord::Base self.group_users.where(user: user).each(&:destroy) end + def appoint_manager(user) + managers << user + end + protected def name_format_validator @@ -294,6 +302,12 @@ class Group < ActiveRecord::Base @deletions = nil end + def automatic_group_membership + if self.automatic_membership_retroactive + Jobs.enqueue(:automatic_group_membership, group_id: self.id) + end + end + end # == Schema Information diff --git a/app/models/group_manager.rb b/app/models/group_manager.rb new file mode 100644 index 0000000000..a30d523f51 --- /dev/null +++ b/app/models/group_manager.rb @@ -0,0 +1,19 @@ +class GroupManager < ActiveRecord::Base + belongs_to :group + belongs_to :manager, class_name: "User", foreign_key: :user_id +end + +# == Schema Information +# +# Table name: group_managers +# +# id :integer not null, primary key +# group_id :integer not null +# user_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_group_managers_on_group_id_and_user_id (group_id,user_id) UNIQUE +# diff --git a/app/models/invite.rb b/app/models/invite.rb index 28fb95526d..36194915ed 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -1,6 +1,11 @@ +require_dependency 'rate_limiter' + class Invite < ActiveRecord::Base + include RateLimiter::OnCreateRecord include Trashable + rate_limit :limit_invites_per_day + belongs_to :user belongs_to :topic belongs_to :invited_by, class_name: 'User' @@ -184,6 +189,10 @@ class Invite < ActiveRecord::Base Jobs.enqueue(:invite_email, invite_id: self.id) end + def limit_invites_per_day + RateLimiter.new(invited_by, "invites-per-day:#{Date.today}", SiteSetting.max_invites_per_day, 1.day.to_i) + end + def self.base_directory File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db) end diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index b49a2e9907..e77c7d1375 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -15,59 +15,60 @@ class OptimizedImage < ActiveRecord::Base thumbnail = nil end + # return the previous thumbnail if any + return thumbnail unless thumbnail.nil? + # create the thumbnail otherwise - unless thumbnail - external_copy = Discourse.store.download(upload) if Discourse.store.external? - original_path = if Discourse.store.external? - external_copy.try(:path) - else - Discourse.store.path_for(upload) - end - - if original_path.blank? - Rails.logger.error("Could not find file in the store located at url: #{upload.url}") - else - # create a temp file with the same extension as the original - extension = File.extname(original_path) - temp_file = Tempfile.new(["discourse-thumbnail", extension]) - temp_path = temp_file.path - - if extension =~ /\.svg$/i - FileUtils.cp(original_path, temp_path) - resized = true - else - resized = resize(original_path, temp_path, width, height, opts[:allow_animation]) - end - - if resized - thumbnail = OptimizedImage.create!( - upload_id: upload.id, - sha1: Digest::SHA1.file(temp_path).hexdigest, - extension: File.extname(temp_path), - width: width, - height: height, - url: "", - ) - # store the optimized image and update its url - url = Discourse.store.store_optimized_image(temp_file, thumbnail) - if url.present? - thumbnail.url = url - thumbnail.save - else - Rails.logger.error("Failed to store avatar #{size} for #{upload.url} from #{source}") - end - else - Rails.logger.error("Failed to create optimized image #{width}x#{height} for #{upload.url}") - end - - # close && remove temp file - temp_file.close! - end - - # make sure we remove the cached copy from external stores - external_copy.close! if Discourse.store.external? + external_copy = Discourse.store.download(upload) if Discourse.store.external? + original_path = if Discourse.store.external? + external_copy.try(:path) + else + Discourse.store.path_for(upload) end + if original_path.blank? + Rails.logger.error("Could not find file in the store located at url: #{upload.url}") + else + # create a temp file with the same extension as the original + extension = File.extname(original_path) + temp_file = Tempfile.new(["discourse-thumbnail", extension]) + temp_path = temp_file.path + + if extension =~ /\.svg$/i + FileUtils.cp(original_path, temp_path) + resized = true + else + resized = resize(original_path, temp_path, width, height, opts[:allow_animation]) + end + + if resized + thumbnail = OptimizedImage.create!( + upload_id: upload.id, + sha1: Digest::SHA1.file(temp_path).hexdigest, + extension: extension, + width: width, + height: height, + url: "", + ) + # store the optimized image and update its url + url = Discourse.store.store_optimized_image(temp_file, thumbnail) + if url.present? + thumbnail.url = url + thumbnail.save + else + Rails.logger.error("Failed to store avatar #{size} for #{upload.url} from #{source}") + end + else + Rails.logger.error("Failed to create optimized image #{width}x#{height} for #{upload.url}") + end + + # close && remove temp file + temp_file.close! + end + + # make sure we remove the cached copy from external stores + external_copy.close! if Discourse.store.external? + thumbnail end diff --git a/app/models/post.rb b/app/models/post.rb index 0d6252e199..94d63779eb 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -121,7 +121,7 @@ class Post < ActiveRecord::Base # The key we use in redis to ensure unique posts def unique_post_key - "post-#{user_id}:#{raw_hash}" + "unique-post-#{user_id}:#{raw_hash}" end def store_unique_post_key @@ -132,7 +132,7 @@ class Post < ActiveRecord::Base def matches_recent_post? post_id = $redis.get(unique_post_key) - post_id != nil and post_id != id + post_id != nil and post_id.to_i != id end def raw_hash @@ -251,8 +251,23 @@ class Post < ActiveRecord::Base order('sort_order desc, post_number desc') end - def self.summary - where(["(post_number = 1) or (percent_rank <= ?)", SiteSetting.summary_percent_filter.to_f / 100.0]).limit(SiteSetting.summary_max_results) + def self.summary(topic_id=nil) + # PERF: if you pass in nil it is WAY slower + # pg chokes getting a reasonable plan + topic_id = topic_id ? topic_id.to_i : "posts.topic_id" + + # percent rank has tons of ties + where(["post_number = 1 or id in ( + SELECT p1.id + FROM posts p1 + WHERE p1.percent_rank <= ? AND + p1.topic_id = #{topic_id} + ORDER BY p1.percent_rank + LIMIT ? + )", + SiteSetting.summary_percent_filter.to_f / 100.0, + SiteSetting.summary_max_results + ]) end def update_flagged_posts_count diff --git a/app/models/post_action.rb b/app/models/post_action.rb index b271e24bda..1f10c6f26f 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -127,14 +127,18 @@ class PostAction < ActiveRecord::Base .where(post_id: post.id) .where(post_action_type_id: PostActionType.flag_types.values) + trigger_spam = false actions.each do |action| action.agreed_at = Time.zone.now action.agreed_by_id = moderator.id # so callback is called action.save action.add_moderator_post_if_needed(moderator, :agreed, delete_post) + @trigger_spam = true if action.post_action_type_id == PostActionType.types[:spam] end + DiscourseEvent.trigger(:confirmed_spam_post, post) if @trigger_spam + update_flagged_posts_count end @@ -405,7 +409,7 @@ class PostAction < ActiveRecord::Base def enforce_rules post = Post.with_deleted.where(id: post_id).first - PostAction.auto_close_if_treshold_reached(post.topic) + PostAction.auto_close_if_threshold_reached(post.topic) PostAction.auto_hide_if_needed(user, post, post_action_type_key) SpamRulesEnforcer.enforce!(post.user) if post_action_type_key == :spam end @@ -418,7 +422,7 @@ class PostAction < ActiveRecord::Base MAXIMUM_FLAGS_PER_POST = 3 - def self.auto_close_if_treshold_reached(topic) + def self.auto_close_if_threshold_reached(topic) return if topic.closed? flags = PostAction.active diff --git a/app/models/topic.rb b/app/models/topic.rb index 561d4f36fd..9a0e93d942 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -124,7 +124,7 @@ class Topic < ActiveRecord::Base scope :visible, -> { where(visible: true) } - scope :created_since, lambda { |time_ago| where('created_at > ?', time_ago) } + scope :created_since, lambda { |time_ago| where('topics.created_at > ?', time_ago) } scope :secured, lambda { |guardian=nil| ids = guardian.secure_category_ids if guardian @@ -276,8 +276,10 @@ class Topic < ActiveRecord::Base .visible .secured(Guardian.new(user)) .joins("LEFT OUTER JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{user.id.to_i}") + .joins("LEFT OUTER JOIN users ON users.id = topics.user_id") .where(closed: false, archived: false) .where("COALESCE(topic_users.notification_level, 1) <> ?", TopicUser.notification_levels[:muted]) + .where("COALESCE(users.trust_level, 0) > 0") .created_since(since) .listable_topics .includes(:category) @@ -821,6 +823,7 @@ class Topic < ActiveRecord::Base end + # == Schema Information # # Table name: topics diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index 9ca519085a..3347695d58 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -280,7 +280,7 @@ SQL end def self.ensure_consistency!(topic_id=nil) - update_post_action_cache + update_post_action_cache(topic_id: topic_id) # TODO this needs some reworking, when we mark stuff skipped # we up these numbers so they are not in-sync diff --git a/app/models/trust_level3_requirements.rb b/app/models/trust_level3_requirements.rb index a42775e123..919db83174 100644 --- a/app/models/trust_level3_requirements.rb +++ b/app/models/trust_level3_requirements.rb @@ -134,12 +134,12 @@ class TrustLevel3Requirements end def num_flagged_by_users - PostAction.with_deleted - .where(post_id: flagged_post_ids) - .where.not(user_id: @user.id) - .where.not(agreed_at: nil) - .pluck(:user_id) - .uniq.count + @_num_flagged_by_users ||= PostAction.with_deleted + .where(post_id: flagged_post_ids) + .where.not(user_id: @user.id) + .where.not(agreed_at: nil) + .pluck(:user_id) + .uniq.count end def max_flagged_by_users @@ -212,9 +212,9 @@ class TrustLevel3Requirements end def flagged_post_ids - @user.posts - .with_deleted - .where('created_at > ? AND (spam_count > 0 OR inappropriate_count > 0)', TIME_PERIOD.days.ago) - .pluck(:id) + @_flagged_post_ids ||= @user.posts + .with_deleted + .where('created_at > ? AND (spam_count > 0 OR inappropriate_count > 0)', TIME_PERIOD.days.ago) + .pluck(:id) end end diff --git a/app/models/upload.rb b/app/models/upload.rb index 33432a28a4..f9507aca8e 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -50,78 +50,79 @@ class Upload < ActiveRecord::Base # - content_type # - origin def self.create_for(user_id, file, filename, filesize, options = {}) - # compute the sha sha1 = Digest::SHA1.file(file).hexdigest - # check if the file has already been uploaded - upload = Upload.find_by(sha1: sha1) - # delete the previously uploaded file if there's been an error + + # do we already have that upload? + upload = find_by(sha1: sha1) + + # make sure the previous upload has not failed if upload && upload.url.blank? upload.destroy upload = nil end - # create the upload - unless upload - # initialize a new upload - upload = Upload.new( - user_id: user_id, - original_filename: filename, - filesize: filesize, - sha1: sha1, - url: "" - ) - # trim the origin if any - upload.origin = options[:origin][0...1000] if options[:origin] - # deal with width & height for images - if FileHelper.is_image?(filename) - begin - if filename =~ /\.svg$/i - svg = Nokogiri::XML(file).at_css("svg") - width, height = svg["width"].to_i, svg["height"].to_i - if width == 0 || height == 0 - upload.errors.add(:base, I18n.t("upload.images.size_not_found")) - else - upload.width, upload.height = ImageSizer.resize(width, height) - end - else - # fix orientation first - Upload.fix_image_orientation(file.path) - # retrieve image info - image_info = FastImage.new(file, raise_on_failure: true) - # compute image aspect ratio - upload.width, upload.height = ImageSizer.resize(*image_info.size) - end - # make sure we're at the beginning of the file - # (FastImage and Nokogiri move the pointer) - file.rewind - rescue FastImage::ImageFetchFailure - upload.errors.add(:base, I18n.t("upload.images.fetch_failure")) - rescue FastImage::UnknownImageType - upload.errors.add(:base, I18n.t("upload.images.unknown_image_type")) - rescue FastImage::SizeNotFound - upload.errors.add(:base, I18n.t("upload.images.size_not_found")) - end + # return the previous upload if any + return upload unless upload.nil? - return upload unless upload.errors.empty? - end + # create the upload otherwise + upload = Upload.new + upload.user_id = user_id + upload.original_filename = filename + upload.filesize = filesize + upload.sha1 = sha1 + upload.url = "" + upload.origin = options[:origin][0...1000] if options[:origin] - # create a db record (so we can use the id) - return upload unless upload.save + # deal with width & height for images + upload = resize_image(filename, file, upload) if FileHelper.is_image?(filename) - # store the file and update its url - url = Discourse.store.store_upload(file, upload, options[:content_type]) - if url.present? - upload.url = url - upload.save - else - upload.errors.add(:url, I18n.t("upload.store_failure", { upload_id: upload.id, user_id: user_id })) - end + return upload unless upload.save + + # store the file and update its url + url = Discourse.store.store_upload(file, upload, options[:content_type]) + if url.present? + upload.url = url + upload.save + else + upload.errors.add(:url, I18n.t("upload.store_failure", { upload_id: upload.id, user_id: user_id })) end # return the uploaded file upload end + def self.resize_image(filename, file, upload) + begin + if filename =~ /\.svg$/i + svg = Nokogiri::XML(file).at_css("svg") + width, height = svg["width"].to_i, svg["height"].to_i + if width == 0 || height == 0 + upload.errors.add(:base, I18n.t("upload.images.size_not_found")) + else + upload.width, upload.height = ImageSizer.resize(width, height) + end + else + # fix orientation first + Upload.fix_image_orientation(file.path) + # retrieve image info + image_info = FastImage.new(file, raise_on_failure: true) + # compute image aspect ratio + upload.width, upload.height = ImageSizer.resize(*image_info.size) + end + # make sure we're at the beginning of the file + # (FastImage and Nokogiri move the pointer) + file.rewind + rescue FastImage::ImageFetchFailure + upload.errors.add(:base, I18n.t("upload.images.fetch_failure")) + rescue FastImage::UnknownImageType + upload.errors.add(:base, I18n.t("upload.images.unknown_image_type")) + rescue FastImage::SizeNotFound + upload.errors.add(:base, I18n.t("upload.images.size_not_found")) + end + + upload + end + def self.get_from_url(url) return if url.blank? # we store relative urls, so we need to remove any host/cdn diff --git a/app/models/user.rb b/app/models/user.rb index 0f6c35b1f4..4a8cd72b09 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -52,6 +52,9 @@ class User < ActiveRecord::Base has_many :groups, through: :group_users has_many :secure_categories, through: :groups, source: :categories + has_many :group_managers, dependent: :destroy + has_many :managed_groups, through: :group_managers, source: :group + has_one :user_search_data, dependent: :destroy has_one :api_key, dependent: :destroy @@ -59,7 +62,7 @@ class User < ActiveRecord::Base delegate :last_sent_email_address, :to => :email_logs - before_validation :downcase_email + before_validation :strip_downcase_email validates_presence_of :username validate :username_validator @@ -76,6 +79,7 @@ class User < ActiveRecord::Base after_create :create_user_stat after_create :create_user_profile after_create :ensure_in_trust_level_group + after_create :automatic_group_membership before_save :update_username_lower before_save :ensure_password_is_hashed @@ -182,7 +186,11 @@ class User < ActiveRecord::Base Jobs.enqueue(:send_system_message, user_id: id, message_type: message_type) end - def change_username(new_username) + def change_username(new_username, actor=nil) + if actor && actor != self + StaffActionLogger.new(actor).log_username_change(self, self.username, new_username) + end + self.username = new_username save end @@ -708,6 +716,17 @@ class User < ActiveRecord::Base Group.user_trust_level_change!(id, trust_level) end + def automatic_group_membership + Group.where(automatic: false) + .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") + .each do |group| + domains = group.automatic_membership_email_domains.gsub('.', '\.') + if self.email =~ Regexp.new("@(#{domains})$", true) + group.add(self) rescue ActiveRecord::RecordNotUnique + end + end + end + def create_user_stat stat = UserStat.new(new_since: Time.now) stat.user_id = id @@ -745,8 +764,11 @@ class User < ActiveRecord::Base self.username_lower = username.downcase end - def downcase_email - self.email = self.email.downcase if self.email + def strip_downcase_email + if self.email + self.email = self.email.strip + self.email = self.email.downcase + end end def username_validator diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb index 6516457b1b..73431a9610 100644 --- a/app/models/user_avatar.rb +++ b/app/models/user_avatar.rb @@ -15,22 +15,20 @@ class UserAvatar < ActiveRecord::Base self.last_gravatar_download_attempt = Time.new gravatar_url = "http://www.gravatar.com/avatar/#{email_hash}.png?s=500&d=404" - tempfile = FileHelper.download(gravatar_url, 1.megabyte, "gravatar") + tempfile = FileHelper.download(gravatar_url, SiteSetting.max_image_size_kb.kilobytes, "gravatar") - upload = Upload.create_for(user.id, tempfile, 'gravatar.png', File.size(tempfile.path)) + upload = Upload.create_for(user.id, tempfile, 'gravatar.png', tempfile.size, { origin: gravatar_url }) if gravatar_upload_id != upload.id gravatar_upload.try(:destroy!) self.gravatar_upload = upload save! - else - gravatar_upload end rescue OpenURI::HTTPError save! rescue SocketError # skip saving, we are not connected to the net - Rails.logger.warn "Failed to download gravatar, socket error - user id #{ user.id }" + Rails.logger.warn "Failed to download gravatar, socket error - user id #{user.id}" ensure tempfile.close! if tempfile && tempfile.respond_to?(:close!) end diff --git a/app/models/user_export.rb b/app/models/user_export.rb index 8b01198cfa..b69a98a27c 100644 --- a/app/models/user_export.rb +++ b/app/models/user_export.rb @@ -12,7 +12,7 @@ class UserExport < ActiveRecord::Base def self.remove_old_exports expired_exports = UserExport.where('created_at < ?', 2.days.ago).to_a expired_exports.map do |expired_export| - file_name = "#{expired_export.export_type}-#{expired_export.id}.csv.gz" + file_name = "#{expired_export.file_name}-#{expired_export.id}.csv.gz" file_path = "#{UserExport.base_directory}/#{file_name}" if File.exist?(file_path) @@ -33,7 +33,7 @@ end # Table name: user_exports # # id :integer not null, primary key -# export_type :string(255) not null +# file_name :string(255) not null # user_id :integer not null # created_at :datetime # updated_at :datetime diff --git a/app/models/user_history.rb b/app/models/user_history.rb index b4bfcad6a8..37722e4e57 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -34,7 +34,8 @@ class UserHistory < ActiveRecord::Base :delete_post, :delete_topic, :impersonate, - :roll_up) + :roll_up, + :change_username) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. @@ -52,7 +53,8 @@ class UserHistory < ActiveRecord::Base :delete_post, :delete_topic, :impersonate, - :roll_up] + :roll_up, + :change_username] end def self.staff_action_ids diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index 8f1574cb97..0e22994212 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -1,3 +1,10 @@ class BasicGroupSerializer < ApplicationSerializer - attributes :id, :automatic, :name, :user_count, :alias_level, :visible + attributes :id, + :automatic, + :name, + :user_count, + :alias_level, + :visible, + :automatic_membership_email_domains, + :automatic_membership_retroactive end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 7264c65a31..f07cd907e6 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -9,6 +9,7 @@ class CurrentUserSerializer < BasicUserSerializer :site_flagged_posts_count, :moderator?, :staff?, + :title, :reply_count, :topic_count, :enable_quoting, diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 637f074538..0565a1a003 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -22,10 +22,25 @@ class PostRevisionSerializer < ApplicationSerializer :edit_reason, :body_changes, :title_changes, - :category_changes, - :user_changes, - :wiki_changes, - :post_type_changes + :user_changes + + + # Creates a field called field_name_changes with previous and + # current members if a field has changed in this revision + def self.add_compared_field(field) + changes_name = "#{field}_changes".to_sym + + self.attributes changes_name + define_method(changes_name) do + { previous: previous[field], current: current[field] } + end + + define_method("include_#{changes_name}?") do + previous[field] != current[field] + end + end + + add_compared_field :wiki def previous_hidden previous["hidden"] @@ -109,17 +124,6 @@ class PostRevisionSerializer < ApplicationSerializer } end - def category_changes - prev = previous["category_id"] - cur = current["category_id"] - return if prev == cur - - { - previous: prev, - current: cur, - } - end - def user_changes prev = previous["user_id"] cur = current["user_id"] @@ -143,28 +147,6 @@ class PostRevisionSerializer < ApplicationSerializer } end - def wiki_changes - prev = previous["wiki"] - cur = current["wiki"] - return if prev == cur - - { - previous: prev, - current: cur, - } - end - - def post_type_changes - prev = previous["post_type"] - cur = current["post_type"] - return if prev == cur - - { - previous: prev, - current: cur, - } - end - protected def post @@ -183,19 +165,27 @@ class PostRevisionSerializer < ApplicationSerializer return @all_revisions if @all_revisions post_revisions = PostRevision.where(post_id: object.post_id).order(:number).to_a + + latest_modifications = { + "raw" => [post.raw], + "cooked" => [post.cooked], + "edit_reason" => [post.edit_reason], + "wiki" => [post.wiki], + "post_type" => [post.post_type], + "user_id" => [post.user_id] + } + + # Retrieve any `tracked_topic_fields` + PostRevisor.tracked_topic_fields.keys.each do |field| + if topic.respond_to?(field) + latest_modifications[field.to_s] = [topic.send(field)] + end + end + post_revisions << PostRevision.new( number: post_revisions.last.number + 1, hidden: post.hidden, - modifications: { - "raw" => [post.raw], - "cooked" => [post.cooked], - "edit_reason" => [post.edit_reason], - "wiki" => [post.wiki], - "post_type" => [post.post_type], - "user_id" => [post.user_id], - "title" => [topic.title], - "category_id" => [topic.category_id], - } + modifications: latest_modifications ) @all_revisions = [] diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index b6ca0ba921..3496159a52 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -1,16 +1,22 @@ class PostSerializer < BasicPostSerializer # To pass in additional information we might need - attr_accessor :topic_view, + INSTANCE_VARS = [:topic_view, :parent_post, :add_raw, :single_post_link_counts, :draft_sequence, - :post_actions + :post_actions, + :all_post_actions] + + INSTANCE_VARS.each do |v| + self.send(:attr_accessor, v) + end attributes :post_number, :post_type, :updated_at, + :like_count, :reply_count, :reply_to_post_number, :quote_count, @@ -53,6 +59,15 @@ class PostSerializer < BasicPostSerializer :static_doc, :via_email + def initialize(object, opts) + super(object, opts) + PostSerializer::INSTANCE_VARS.each do |name| + if opts.include? name + self.send("#{name}=", opts[name]) + end + end + end + def topic_slug object.try(:topic).try(:slug) end @@ -154,6 +169,13 @@ class PostSerializer < BasicPostSerializer scope.is_staff? && object.deleted_by.present? end + # Helper function to decide between #post_actions and @all_post_actions + def actions + return post_actions if post_actions.present? + return all_post_actions[object.id] if all_post_actions.present? + nil + end + # Summary of the actions taken on this post def actions_summary result = [] @@ -167,7 +189,7 @@ class PostSerializer < BasicPostSerializer id: id, count: count, hidden: (sym == :vote), - can_act: scope.post_can_act?(object, sym, taken_actions: post_actions) + can_act: scope.post_can_act?(object, sym, taken_actions: actions) } if sym == :notify_user && scope.current_user.present? && scope.current_user == object.user @@ -182,9 +204,9 @@ class PostSerializer < BasicPostSerializer active_flags[id].count > 0 end - if post_actions.present? && post_actions.has_key?(id) + if actions.present? && actions.has_key?(id) action_summary[:acted] = true - action_summary[:can_undo] = scope.can_delete?(post_actions[id]) + action_summary[:can_undo] = scope.can_delete?(actions[id]) end # only show public data @@ -225,7 +247,7 @@ class PostSerializer < BasicPostSerializer end def include_bookmarked? - post_actions.present? && post_actions.keys.include?(PostActionType.types[:bookmark]) + actions.present? && actions.keys.include?(PostActionType.types[:bookmark]) end def include_display_username? diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index d0fc7f281a..eec7b2c447 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -3,29 +3,35 @@ require_dependency 'pinned_check' class TopicViewSerializer < ApplicationSerializer include PostStreamSerializerMixin - # These attributes will be delegated to the topic - def self.topic_attributes - [:id, - :title, - :fancy_title, - :posts_count, - :created_at, - :views, - :reply_count, - :participant_count, - :like_count, - :last_posted_at, - :visible, - :closed, - :archived, - :has_summary, - :archetype, - :slug, - :category_id, - :word_count, - :deleted_at] + def self.attributes_from_topic(*list) + [list].flatten.each do |attribute| + attributes(attribute) + class_eval %{def #{attribute} + object.topic.#{attribute} + end} + end end + attributes_from_topic :id, + :title, + :fancy_title, + :posts_count, + :created_at, + :views, + :reply_count, + :participant_count, + :like_count, + :last_posted_at, + :visible, + :closed, + :archived, + :has_summary, + :archetype, + :slug, + :category_id, + :word_count, + :deleted_at + attributes :draft, :draft_key, :draft_sequence, @@ -45,14 +51,6 @@ class TopicViewSerializer < ApplicationSerializer :chunk_size, :bookmarked - # Define a delegator for each attribute of the topic we want - attributes(*topic_attributes) - topic_attributes.each do |ta| - class_eval %{def #{ta} - object.topic.#{ta} - end} - end - # TODO: Split off into proper object / serializer def details result = { diff --git a/app/serializers/user_field_serializer.rb b/app/serializers/user_field_serializer.rb index ff2da805e1..8638b2c04d 100644 --- a/app/serializers/user_field_serializer.rb +++ b/app/serializers/user_field_serializer.rb @@ -4,5 +4,6 @@ class UserFieldSerializer < ApplicationSerializer :description, :field_type, :editable, - :required + :required, + :show_on_profile end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 3361d0291c..4d5da91c00 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -118,7 +118,13 @@ class PostAlerter if collapsed post = first_unread_post(user,post.topic) || post count = unread_count(user, post.topic) - opts[:display_username] = I18n.t('embed.replies', count: count) if count > 1 + I18n.with_locale(if SiteSetting.allow_user_locale && user.locale.present? + user.locale + else + SiteSetting.default_locale + end) do + opts[:display_username] = I18n.t('embed.replies', count: count) if count > 1 + end end UserActionObserver.log_notification(original_post, user, type, opts[:acting_user_id]) diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index fc6bfdf100..620c1138f5 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -100,6 +100,16 @@ class StaffActionLogger })) end + def log_username_change(user, old_username, new_username, opts={}) + raise Discourse::InvalidParameters.new('user is nil') unless user + UserHistory.create( params(opts).merge({ + action: UserHistory.actions[:change_username], + target_user_id: user.id, + previous_value: old_username, + new_value: new_username + })) + end + def log_user_suspend(user, reason, opts={}) raise Discourse::InvalidParameters.new('user is nil') unless user UserHistory.create( params(opts).merge({ diff --git a/app/views/exceptions/not_found.html.erb b/app/views/exceptions/not_found.html.erb index 4a4a7db01c..47ab572429 100644 --- a/app/views/exceptions/not_found.html.erb +++ b/app/views/exceptions/not_found.html.erb @@ -6,7 +6,9 @@