From e8a30431297a5a908e5bd2fc4e63e515412efa60 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 10 Nov 2016 23:44:51 +0800 Subject: [PATCH 001/122] Spawn a single thread that checks for PostgreSQL fallback. --- .../postgresql_fallback_adapter.rb | 126 +++++++----------- lib/discourse.rb | 10 +- .../postgresql_fallback_adapter_spec.rb | 31 ++--- 3 files changed, 64 insertions(+), 103 deletions(-) diff --git a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb index b52a6c12ac..03932d6cc7 100644 --- a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb +++ b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb @@ -6,23 +6,46 @@ class PostgreSQLFallbackHandler include Singleton def initialize - @master = {} - @running = {} - @mutex = {} - @last_check = {} - - setup! + @masters_down = {} + @mutex = Mutex.new end def verify_master - @mutex[namespace].synchronize do - return if running || recently_checked? - @running[namespace] = true - end + synchronize { return if @thread && @thread.alive? } - current_namespace = namespace - Thread.new do - RailsMultisite::ConnectionManagement.with_connection(current_namespace) do + @thread = Thread.new do + while true do + begin + thread = Thread.new { initiate_fallback_to_master } + thread.join + break if synchronize { @masters_down.empty? } + sleep 10 + ensure + thread.kill + end + end + end + end + + def master_down? + synchronize { @masters_down[namespace] } + end + + def master_down=(args) + synchronize { @masters_down[namespace] = args } + end + + def master_up(namespace) + synchronize { @masters_down.delete(namespace) } + end + + def running? + synchronize { @thread.alive? } + end + + def initiate_fallback_to_master + @masters_down.keys.each do |key| + RailsMultisite::ConnectionManagement.with_connection(key) do begin logger.warn "#{log_prefix}: Checking master server..." connection = ActiveRecord::Base.postgresql_connection(config) @@ -32,54 +55,20 @@ class PostgreSQLFallbackHandler ActiveRecord::Base.clear_all_connections! logger.warn "#{log_prefix}: Master server is active. Reconnecting..." - if namespace == RailsMultisite::ConnectionManagement::DEFAULT - ActiveRecord::Base.establish_connection(config) - else - RailsMultisite::ConnectionManagement.establish_connection(db: namespace) - end - + self.master_up(key) Discourse.disable_readonly_mode - self.master = true end rescue => e - if e.message.include?("could not connect to server") - logger.warn "#{log_prefix}: Connection to master PostgreSQL server failed with '#{e.message}'" - else - raise e - end - ensure - @mutex[namespace].synchronize do - @last_check[namespace] = Time.zone.now - @running[namespace] = false - end + byebug + logger.warn "#{log_prefix}: Connection to master PostgreSQL server failed with '#{e.message}'" end end end end - def master - @master[namespace] - end - - def master=(args) - @master[namespace] = args - end - - def running - @running[namespace] - end - + # Use for testing def setup! - RailsMultisite::ConnectionManagement.all_dbs.each do |db| - @master[db] = true - @running[db] = false - @mutex[db] = Mutex.new - @last_check[db] = nil - end - end - - def verify? - !master && !running + @masters_down = {} end private @@ -96,17 +85,13 @@ class PostgreSQLFallbackHandler "#{self.class} [#{namespace}]" end - def recently_checked? - if @last_check[namespace] - Time.zone.now <= (@last_check[namespace] + 5.seconds) - else - false - end - end - def namespace RailsMultisite::ConnectionManagement.current_db end + + def synchronize + @mutex.synchronize { yield } + end end module ActiveRecord @@ -115,7 +100,7 @@ module ActiveRecord fallback_handler = ::PostgreSQLFallbackHandler.instance config = config.symbolize_keys - if fallback_handler.verify? + if fallback_handler.master_down? connection = postgresql_connection(config.dup.merge({ host: config[:replica_host], port: config[:replica_port] })) @@ -126,7 +111,8 @@ module ActiveRecord begin connection = postgresql_connection(config) rescue PG::ConnectionBad => e - fallback_handler.master = false + fallback_handler.master_down = true + fallback_handler.verify_master raise e end end @@ -141,20 +127,4 @@ module ActiveRecord raise "Replica database server is not in recovery mode." if value == 'f' end end - - module ConnectionAdapters - class PostgreSQLAdapter - set_callback :checkout, :before, :switch_back? - - private - - def fallback_handler - @fallback_handler ||= ::PostgreSQLFallbackHandler.instance - end - - def switch_back? - fallback_handler.verify_master if fallback_handler.verify? - end - end - end end diff --git a/lib/discourse.rb b/lib/discourse.rb index 1ac6e1b00d..f28ad2593c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -228,10 +228,12 @@ module Discourse def self.keep_readonly_mode # extend the expiry by 1 minute every 30 seconds - Thread.new do - while readonly_mode? - $redis.expire(READONLY_MODE_KEY, READONLY_MODE_KEY_TTL) - sleep 30.seconds + unless Rails.env.test? + Thread.new do + while readonly_mode? + $redis.expire(READONLY_MODE_KEY, READONLY_MODE_KEY_TTL) + sleep 30.seconds + end end end end diff --git a/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb b/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb index 05e6b68cd9..a9e61dbea1 100644 --- a/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb +++ b/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb @@ -48,8 +48,9 @@ describe ActiveRecord::ConnectionHandling do end it 'should failover to a replica server' do + current_threads = Thread.list + RailsMultisite::ConnectionManagement.stubs(:all_dbs).returns(['default', multisite_db]) - ::PostgreSQLFallbackHandler.instance.setup! [config, multisite_config].each do |configuration| ActiveRecord::Base.expects(:postgresql_connection).with(configuration).raises(PG::ConnectionBad) @@ -60,7 +61,7 @@ describe ActiveRecord::ConnectionHandling do })).returns(@replica_connection) end - expect(postgresql_fallback_handler.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect { ActiveRecord::Base.postgresql_fallback_connection(config) } .to raise_error(PG::ConnectionBad) @@ -68,10 +69,10 @@ describe ActiveRecord::ConnectionHandling do expect{ ActiveRecord::Base.postgresql_fallback_connection(config) } .to change{ Discourse.readonly_mode? }.from(false).to(true) - expect(postgresql_fallback_handler.master).to eq(false) + expect(postgresql_fallback_handler.master_down?).to eq(true) with_multisite_db(multisite_db) do - expect(postgresql_fallback_handler.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect { ActiveRecord::Base.postgresql_fallback_connection(multisite_config) } .to raise_error(PG::ConnectionBad) @@ -79,30 +80,18 @@ describe ActiveRecord::ConnectionHandling do expect{ ActiveRecord::Base.postgresql_fallback_connection(multisite_config) } .to change{ Discourse.readonly_mode? }.from(false).to(true) - expect(postgresql_fallback_handler.master).to eq(false) + expect(postgresql_fallback_handler.master_down?).to eq(true) end + postgresql_fallback_handler.master_up(multisite_db) + ActiveRecord::Base.unstub(:postgresql_connection) - current_threads = Thread.list - - expect{ ActiveRecord::Base.connection_pool.checkout } - .to change{ Thread.list.size }.by(1) - - # Ensure that we don't try to connect back to the replica when a thread - # is running - begin - ActiveRecord::Base.postgresql_fallback_connection(config) - rescue PG::ConnectionBad => e - # This is expected if the thread finishes before the above is called. - end - - # Wait for the thread to finish execution - (Thread.list - current_threads).each(&:join) + postgresql_fallback_handler.initiate_fallback_to_master expect(Discourse.readonly_mode?).to eq(false) - expect(PostgreSQLFallbackHandler.instance.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect(ActiveRecord::Base.connection_pool.connections.count).to eq(0) From 759feef3f03c2f2f524b9acbdc10df21a506205c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Nov 2016 11:39:19 +0800 Subject: [PATCH 002/122] FIX: No loggers may have been chained. --- config/initializers/100-logster.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/100-logster.rb b/config/initializers/100-logster.rb index c39494f6a0..d913051336 100644 --- a/config/initializers/100-logster.rb +++ b/config/initializers/100-logster.rb @@ -87,5 +87,6 @@ RailsMultisite::ConnectionManagement.each_connection do end if Rails.configuration.multisite - Rails.logger.instance_variable_get(:@chained).first.formatter = RailsMultisite::Formatter.new + chained = Rails.logger.instance_variable_get(:@chained) + chained && chained.first.formatter = RailsMultisite::Formatter.new end From c74a5771fdc8617cf0b95055f5664b0d73da4bf2 Mon Sep 17 00:00:00 2001 From: Thomas Ferracin Date: Thu, 24 Nov 2016 18:01:08 +0100 Subject: [PATCH 003/122] FIX uploadLocation when window.location.port is empty More information available: https://meta.discourse.org/t/uploaded-video-are-not-embedded-and-display-a-link/53346 --- app/assets/javascripts/discourse/lib/utilities.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 0385fa6555..ee160fb6fe 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -244,7 +244,7 @@ export function uploadLocation(url) { } else { var protocol = window.location.protocol + '//', hostname = window.location.hostname, - port = ':' + window.location.port; + port = window.location.port ? ':' + window.location.port : ''; return protocol + hostname + port + url; } } From 66ca6d622e54e7958297fcc4e4cfac3e71cb3628 Mon Sep 17 00:00:00 2001 From: cpradio Date: Mon, 28 Nov 2016 09:57:18 -0500 Subject: [PATCH 004/122] FEATURE: Add min_post_count search filter --- .../components/search-advanced-options.js.es6 | 36 +++++++++---------- .../components/search-advanced-options.hbs | 4 +-- lib/search.rb | 4 +++ spec/components/search_spec.rb | 1 + .../acceptance/search-full-test.js.es6 | 12 +++---- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index b45f54b530..6d78fb2b6a 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -1,16 +1,16 @@ import { observes } from 'ember-addons/ember-computed-decorators'; -const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; +const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; -const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; -const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; -const REGEXP_GROUP_PREFIX = /group:/ig; -const REGEXP_BADGE_PREFIX = /badge:/ig; -const REGEXP_TAGS_PREFIX = /tags?:/ig; -const REGEXP_IN_PREFIX = /in:/ig; -const REGEXP_STATUS_PREFIX = /status:/ig; -const REGEXP_POST_COUNT_PREFIX = /posts_count:/ig; -const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; +const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; +const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; +const REGEXP_GROUP_PREFIX = /group:/ig; +const REGEXP_BADGE_PREFIX = /badge:/ig; +const REGEXP_TAGS_PREFIX = /tags?:/ig; +const REGEXP_IN_PREFIX = /in:/ig; +const REGEXP_STATUS_PREFIX = /status:/ig; +const REGEXP_MIN_POST_COUNT_PREFIX = /min_post_count:/ig; +const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; const REGEXP_IN_MATCH = /in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig; const REGEXP_SPECIAL_IN_LIKES_MATCH = /in:likes/ig; @@ -73,7 +73,7 @@ export default Em.Component.extend({ } }, status: '', - posts_count: '', + min_post_count: '', time: { when: 'before', days: '' @@ -99,7 +99,7 @@ export default Em.Component.extend({ this.setSearchedTermSpecialInValue('searchedTerms.special.in.wiki', REGEXP_SPECIAL_IN_WIKI_MATCH); this.setSearchedTermValue('searchedTerms.status', REGEXP_STATUS_PREFIX); this.setSearchedTermValueForPostTime(); - this.setSearchedTermValue('searchedTerms.posts_count', REGEXP_POST_COUNT_PREFIX); + this.setSearchedTermValue('searchedTerms.min_post_count', REGEXP_MIN_POST_COUNT_PREFIX); }, findSearchTerms() { @@ -490,17 +490,17 @@ export default Em.Component.extend({ } }, - @observes('searchedTerms.posts_count') - updateSearchTermForPostsCount() { - const match = this.filterBlocks(REGEXP_POST_COUNT_PREFIX); - const postsCountFilter = this.get('searchedTerms.posts_count'); + @observes('searchedTerms.min_post_count') + updateSearchTermForMinPostCount() { + const match = this.filterBlocks(REGEXP_MIN_POST_COUNT_PREFIX); + const postsCountFilter = this.get('searchedTerms.min_post_count'); let searchTerm = this.get('searchTerm') || ''; if (postsCountFilter) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match[0], `posts_count:${postsCountFilter}`); + searchTerm = searchTerm.replace(match[0], `min_post_count:${postsCountFilter}`); } else { - searchTerm += ` posts_count:${postsCountFilter}`; + searchTerm += ` min_post_count:${postsCountFilter}`; } this.set('searchTerm', searchTerm.trim()); diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index 554a2d689c..5ac55038d5 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -75,9 +75,9 @@
- +
- {{input type="number" value=searchedTerms.posts_count class="input-small" id='search-posts-count'}} + {{input type="number" value=searchedTerms.min_post_count class="input-small" id='search-min-post-count'}}
diff --git a/lib/search.rb b/lib/search.rb index e9f3cddc16..f6746980d9 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -252,6 +252,10 @@ class Search posts.where("topics.posts_count = ?", match.to_i) end + advanced_filter(/min_post_count:(\d+)/) do |posts, match| + posts.where("topics.posts_count >= ?", match.to_i) + end + advanced_filter(/in:first/) do |posts| posts.where("posts.post_number = 1") end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index 20c2046e0b..8d054157b1 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -532,6 +532,7 @@ describe Search do expect(Search.execute('test status:closed').posts.length).to eq(0) expect(Search.execute('test status:open').posts.length).to eq(1) expect(Search.execute('test posts_count:1').posts.length).to eq(1) + expect(Search.execute('test min_post_count:1').posts.length).to eq(1) topic.closed = true topic.save diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index 29d863912d..a9e75f812c 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -71,7 +71,7 @@ test("open advanced search", assert => { test("validate population of advanced search", assert => { visit("/search"); - fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes in:private in:wiki in:bookmarks status:open after:2016-10-05 posts_count:10'); + fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes in:private in:wiki in:bookmarks status:open after:2016-10-05 min_post_count:10'); click('.search-advanced-btn'); andThen(() => { @@ -87,7 +87,7 @@ test("validate population of advanced search", assert => { assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("are open")'), 'has "are open" pre-populated'); assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("after")'), 'has "after" pre-populated'); assert.equal(find('.search-advanced-options #search-post-date').val(), "2016-10-05", 'has "2016-10-05" pre-populated'); - assert.equal(find('.search-advanced-options #search-posts-count').val(), "10", 'has "10" pre-populated'); + assert.equal(find('.search-advanced-options #search-min-post-count').val(), "10", 'has "10" pre-populated'); }); }); @@ -270,15 +270,15 @@ test("update post time through advanced search ui", assert => { }); }); -test("update posts count through advanced search ui", assert => { +test("update min post count through advanced search ui", assert => { visit("/search"); fillIn('.search input.full-page-search', 'none'); click('.search-advanced-btn'); - fillIn('#search-posts-count', '5'); + fillIn('#search-min-post-count', '5'); andThen(() => { - assert.equal(find('.search-advanced-options #search-posts-count').val(), "5", 'has "5" populated'); - assert.equal(find('.search input.full-page-search').val(), "none posts_count:5", 'has updated search term to "none posts_count:5"'); + assert.equal(find('.search-advanced-options #search-min-post-count').val(), "5", 'has "5" populated'); + assert.equal(find('.search input.full-page-search').val(), "none min_post_count:5", 'has updated search term to "none min_post_count:5"'); }); }); From d95fbd89d0e3adf3f6cfcae1a4781abc3b7eaa73 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 10:59:10 +0800 Subject: [PATCH 005/122] Enable miniprofiler in development automatically. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 223189b28d..a6a1300b51 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -465,7 +465,7 @@ class ApplicationController < ActionController::Base end def mini_profiler_enabled? - defined?(Rack::MiniProfiler) && guardian.is_developer? + defined?(Rack::MiniProfiler) && (guardian.is_developer? || Rails.env.development?) end def authorize_mini_profiler From dfc383a9483cdfabee0a552ff1e672696733aea6 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 12:37:40 +0800 Subject: [PATCH 006/122] UX: Capitalize group name. --- app/assets/javascripts/discourse/controllers/group.js.es6 | 6 +++++- app/assets/javascripts/discourse/templates/group.hbs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index 56a5325d2a..9e8fbd83c0 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -12,7 +12,6 @@ var Tab = Em.Object.extend({ } }); - export default Ember.Controller.extend({ counts: null, showing: 'members', @@ -24,6 +23,11 @@ export default Ember.Controller.extend({ Tab.create({ name: 'messages', requiresMembership: true }) ], + @computed('model.name') + groupName(name) { + return name.capitalize(); + }, + @observes('counts') countsChanged() { const counts = this.get('counts'); diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 416b356f40..997d481183 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -17,7 +17,7 @@
-

{{model.name}}

+

{{groupName}}

{{outlet}} From 26db5d4c11a03b3cc23ec542bbb1faf18531d30b Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 29 Nov 2016 16:16:20 +1100 Subject: [PATCH 007/122] FIX: correctly specify outlet adds LI --- app/assets/javascripts/discourse/templates/user/activity.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/user/activity.hbs b/app/assets/javascripts/discourse/templates/user/activity.hbs index c5243f1ffb..4d4ed6ce3f 100644 --- a/app/assets/javascripts/discourse/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/templates/user/activity.hbs @@ -23,7 +23,7 @@ {{/link-to}} {{/if}} - {{plugin-outlet "user-activity-bottom"}} + {{plugin-outlet "user-activity-bottom" tagName='li'}} {{/mobile-nav}} {{#if viewingSelf}} From 1939104d469d5e003c6e2f341fcaeb35808c6a19 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 29 Nov 2016 16:17:14 +1100 Subject: [PATCH 008/122] Add mapping for Discourse solved This basically reserved user action 15 for Discourse solved. --- app/models/user_action.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/user_action.rb b/app/models/user_action.rb index 23678754cf..042d4a5397 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -18,6 +18,7 @@ class UserAction < ActiveRecord::Base NEW_PRIVATE_MESSAGE = 12 GOT_PRIVATE_MESSAGE = 13 PENDING = 14 + SOLVED = 15 ORDER = Hash[*[ GOT_PRIVATE_MESSAGE, @@ -31,7 +32,8 @@ class UserAction < ActiveRecord::Base MENTION, QUOTE, BOOKMARK, - EDIT + EDIT, + SOLVED, ].each_with_index.to_a.flatten] # note, this is temporary until we upgrade to rails 4 From 0d4f71e90b59b509e4c9ddd2c686cd20ad4cc27d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 13:42:47 +0800 Subject: [PATCH 009/122] FIX: Display group avatar flair on user page. --- .../discourse/controllers/group.js.es6 | 10 ++++++++++ .../javascripts/discourse/templates/group.hbs | 9 ++++++++- .../discourse/widgets/avatar-flair.js.es6 | 2 +- app/assets/stylesheets/common/base/groups.scss | 16 ++++++++++++++++ test/javascripts/acceptance/groups-test.js.es6 | 2 ++ test/javascripts/fixtures/group-fixtures.js.es6 | 3 ++- 6 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 app/assets/stylesheets/common/base/groups.scss diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index 9e8fbd83c0..2c72b5475a 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -28,6 +28,16 @@ export default Ember.Controller.extend({ return name.capitalize(); }, + @computed('model.name', 'model.flair_url', 'model.flair_bg_color', 'model.flair_color') + avatarFlairAttributes(groupName, flairURL, flairBgColor, flairColor) { + return { + primary_group_flair_url: flairURL, + primary_group_flair_bg_color: flairBgColor, + primary_group_flair_color: flairColor, + primary_group_name: groupName + }; + }, + @observes('counts') countsChanged() { const counts = this.get('counts'); diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 997d481183..f3f96d4efe 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -17,7 +17,14 @@
-

{{groupName}}

+

+ {{#if model.flair_url}} + + {{mount-widget widget="avatar-flair" args=avatarFlairAttributes}} + + {{/if}} + {{groupName}} +

{{outlet}} diff --git a/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 b/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 index 55beefc8cd..265d6a69b5 100644 --- a/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 +++ b/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 @@ -37,4 +37,4 @@ createWidget('avatar-flair', { return []; } } -}); \ No newline at end of file +}); diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss new file mode 100644 index 0000000000..76930759f7 --- /dev/null +++ b/app/assets/stylesheets/common/base/groups.scss @@ -0,0 +1,16 @@ +.groups { + .group-header { + display: table; + } + + .avatar-flair { + background-size: 40px; + height: 40px; + width: 40px; + } + + .group-avatar-flair, .group-name { + display: table-cell; + vertical-align: middle; + } +} diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 0fcc909999..3b01016a94 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -4,7 +4,9 @@ acceptance("Groups"); test("Browsing Groups", () => { visit("/groups/discourse"); + andThen(() => { + ok(count('.avatar-flair .fa-adjust') === 1, "it displays the group's avatar flair"); ok(count('.group-members tr') > 0, "it lists group members"); }); diff --git a/test/javascripts/fixtures/group-fixtures.js.es6 b/test/javascripts/fixtures/group-fixtures.js.es6 index 8a2d2fa252..d6ea20a239 100644 --- a/test/javascripts/fixtures/group-fixtures.js.es6 +++ b/test/javascripts/fixtures/group-fixtures.js.es6 @@ -6,7 +6,8 @@ export default { "name":"discourse", "user_count":8, "alias_level":0, - "visible":true + "visible":true, + "flair_url": 'fa-adjust' } }, "/groups/discourse/counts.json":{ From efe24f7cc6f768491c9f35b664f72be66d29342f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 14:14:44 +0800 Subject: [PATCH 010/122] Fix style for font-awesome avatar flair. --- app/assets/stylesheets/common/base/groups.scss | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss index 76930759f7..15120e7242 100644 --- a/app/assets/stylesheets/common/base/groups.scss +++ b/app/assets/stylesheets/common/base/groups.scss @@ -4,9 +4,15 @@ } .avatar-flair { - background-size: 40px; - height: 40px; - width: 40px; + $size: 40px; + + background-size: $size; + height: $size; + width: $size; + + i { + font-size: $size !important; + } } .group-avatar-flair, .group-name { From 266322ce2edf8c532e1044f06516e8098fc1a5a2 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 29 Nov 2016 17:55:39 +1100 Subject: [PATCH 011/122] FEATURE: add help text for no bookmarks in user page --- .../discourse/models/user-stream.js.es6 | 22 ++++++++++++++++--- .../routes/user-activity-bookmarks.js.es6 | 3 ++- .../routes/user-activity-likes-given.js.es6 | 3 ++- .../routes/user-activity-stream.js.es6 | 2 +- .../discourse/templates/user/stream.hbs | 5 +++++ app/controllers/user_actions_controller.rb | 16 +++++++++++++- config/locales/server.en.yml | 8 +++++++ .../user_actions_controller_spec.rb | 22 +++++++++++++++++++ 8 files changed, 74 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/models/user-stream.js.es6 b/app/assets/javascripts/discourse/models/user-stream.js.es6 index 49c02b4e61..fc80dc94ef 100644 --- a/app/assets/javascripts/discourse/models/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-stream.js.es6 @@ -28,11 +28,21 @@ export default RestModel.extend({ baseUrl: url('itemsLoaded', 'user.username_lower', '/user_actions.json?offset=%@&username=%@'), - filterBy(filter) { - this.setProperties({ filter, itemsLoaded: 0, content: [], lastLoadedUrl: null }); + filterBy(filter, noContentHelpKey) { + this.setProperties({ + filter, + itemsLoaded: 0, + content: [], + noContentHelpKey: noContentHelpKey, + lastLoadedUrl: null + }); return this.findItems(); }, + noContent: function() { + return this.get('loaded') && this.get('content').length === 0; + }.property('loaded', 'content.@each'), + remove(userAction) { // 1) remove the user action from the child groups this.get("content").forEach(function (ua) { @@ -61,6 +71,9 @@ export default RestModel.extend({ if (this.get('filterParam')) { findUrl += "&filter=" + this.get('filterParam'); } + if (this.get('noContentHelpKey')) { + findUrl += "&no_results_help_key=" + this.get('noContentHelpKey'); + } // Don't load the same stream twice. We're probably at the end. const lastLoadedUrl = this.get('lastLoadedUrl'); @@ -69,6 +82,9 @@ export default RestModel.extend({ if (this.get('loading')) { return Ember.RSVP.resolve(); } this.set('loading', true); return ajax(findUrl, {cache: 'false'}).then( function(result) { + if (result && result.no_results_help) { + self.set('noContentHelp', result.no_results_help); + } if (result && result.user_actions) { const copy = Em.A(); result.user_actions.forEach(function(action) { @@ -78,11 +94,11 @@ export default RestModel.extend({ self.get('content').pushObjects(UserAction.collapseStream(copy)); self.setProperties({ - loaded: true, itemsLoaded: self.get('itemsLoaded') + result.user_actions.length }); } }).finally(function() { + self.set('loaded', true); self.set('loading', false); self.set('lastLoadedUrl', findUrl); }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 index cb14c18153..49ad23d1bc 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 @@ -2,5 +2,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: UserAction.TYPES["bookmarks"] + userActionType: UserAction.TYPES["bookmarks"], + noContentHelpKey: "user_activity.no_bookmarks" }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 index 13a0e5b986..852f67ab27 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 @@ -2,5 +2,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: UserAction.TYPES["likes_given"] + userActionType: UserAction.TYPES["likes_given"], + noContentHelpKey: 'no_likes_given' }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 index 7447d71217..4fbf8e4300 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 @@ -6,7 +6,7 @@ export default Discourse.Route.extend(ViewingActionType, { }, afterModel() { - return this.modelFor("user").get("stream").filterBy(this.get("userActionType")); + return this.modelFor("user").get("stream").filterBy(this.get("userActionType"), this.get("noContentHelpKey")); }, renderTemplate() { diff --git a/app/assets/javascripts/discourse/templates/user/stream.hbs b/app/assets/javascripts/discourse/templates/user/stream.hbs index 0a06e728c8..220ae57ba8 100644 --- a/app/assets/javascripts/discourse/templates/user/stream.hbs +++ b/app/assets/javascripts/discourse/templates/user/stream.hbs @@ -1,3 +1,8 @@ +{{#if model.noContent}} +
+ {{{model.noContentHelp}}} +
+{{/if}} {{#user-stream stream=model}} {{#each model.content as |item|}} {{stream-item item=item removeBookmark="removeBookmark"}} diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index 6ff74ec995..fea9ec4183 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -24,7 +24,21 @@ class UserActionsController < ApplicationController UserAction.stream(opts) end - render_serialized(stream, UserActionSerializer, root: 'user_actions') + stream = stream.to_a + if stream.length == 0 && (help_key = params['no_results_help_key']) + if user.id == guardian.user.try(:id) + help_key += ".self" + else + help_key += ".other" + end + render json: { + user_action: [], + no_results_help: I18n.t(help_key) + } + else + render_serialized(stream, UserActionSerializer, root: 'user_actions') + end + end def show diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 573685dca4..09e54a1060 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -599,6 +599,14 @@ en: description: 'Vote for this post' long_form: 'voted for this post' + user_activity: + no_bookmarks: + self: "You have no bookmarked posts, bookmarking posts allows you to easily access them later on." + other: "No bookmarks." + no_likes_given: + self: "You have not liked any posts." + other: "No liked posts." + topic_flag_types: spam: title: 'Spam' diff --git a/spec/controllers/user_actions_controller_spec.rb b/spec/controllers/user_actions_controller_spec.rb index a161c2f90d..ca0c0df4a5 100644 --- a/spec/controllers/user_actions_controller_spec.rb +++ b/spec/controllers/user_actions_controller_spec.rb @@ -24,6 +24,28 @@ describe UserActionsController do expect(action["post_number"]).to eq(1) end + it 'renders help text if provided for self' do + logged_in = log_in + + xhr :get, :index, filter: UserAction::LIKE, username: logged_in.username, no_results_help_key: "user_activity.no_bookmarks" + + expect(response.status).to eq(200) + parsed = JSON.parse(response.body) + + expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.self")) + + end + + it 'renders help text for others' do + user = Fabricate(:user) + xhr :get, :index, filter: UserAction::LIKE, username: user.username, no_results_help_key: "user_activity.no_bookmarks" + + expect(response.status).to eq(200) + parsed = JSON.parse(response.body) + + expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.other")) + end + context "queued posts" do context "without access" do let(:user) { Fabricate(:user) } From b8dc58be9073abb06ef633d1838f18ac6225d68a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 29 Nov 2016 18:01:09 +1100 Subject: [PATCH 012/122] got to be careful with integrity specs --- app/controllers/user_actions_controller.rb | 2 +- config/locales/server.en.yml | 4 ++-- spec/controllers/user_actions_controller_spec.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index fea9ec4183..71dd2a5a12 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -29,7 +29,7 @@ class UserActionsController < ApplicationController if user.id == guardian.user.try(:id) help_key += ".self" else - help_key += ".other" + help_key += ".others" end render json: { user_action: [], diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 09e54a1060..bf47274199 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -602,10 +602,10 @@ en: user_activity: no_bookmarks: self: "You have no bookmarked posts, bookmarking posts allows you to easily access them later on." - other: "No bookmarks." + others: "No bookmarks." no_likes_given: self: "You have not liked any posts." - other: "No liked posts." + others: "No liked posts." topic_flag_types: spam: diff --git a/spec/controllers/user_actions_controller_spec.rb b/spec/controllers/user_actions_controller_spec.rb index ca0c0df4a5..52583c7f86 100644 --- a/spec/controllers/user_actions_controller_spec.rb +++ b/spec/controllers/user_actions_controller_spec.rb @@ -43,7 +43,7 @@ describe UserActionsController do expect(response.status).to eq(200) parsed = JSON.parse(response.body) - expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.other")) + expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.others")) end context "queued posts" do From 8a18c5be1f6aff80759dbaf8d34a09626a7a2910 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 29 Nov 2016 12:47:34 +0530 Subject: [PATCH 013/122] FIX: use proper locale for user archive download alerts --- .../discourse/controllers/user-activity.js.es6 | 2 +- app/assets/javascripts/discourse/lib/export-csv.js.es6 | 4 ++-- .../javascripts/discourse/templates/user/activity.hbs | 2 +- config/locales/client.en.yml | 8 +++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 index 4e9a063060..624a8b0ae7 100644 --- a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 @@ -23,7 +23,7 @@ export default Ember.Controller.extend({ actions: { exportUserArchive() { bootbox.confirm( - I18n.t("user.download_archive_confirm"), + I18n.t("user.download_archive.confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(confirmed) { diff --git a/app/assets/javascripts/discourse/lib/export-csv.js.es6 b/app/assets/javascripts/discourse/lib/export-csv.js.es6 index a88558b3d1..fe4d10c89b 100644 --- a/app/assets/javascripts/discourse/lib/export-csv.js.es6 +++ b/app/assets/javascripts/discourse/lib/export-csv.js.es6 @@ -8,9 +8,9 @@ function exportEntityByType(type, entity, args) { export function exportUserArchive() { return exportEntityByType('user', 'user_archive').then(function() { - bootbox.alert(I18n.t("admin.export_csv.success")); + bootbox.alert(I18n.t("user.download_archive.success")); }).catch(function() { - bootbox.alert(I18n.t("admin.export_csv.rate_limit_error")); + bootbox.alert(I18n.t("user.download_archive.rate_limit_error")); }); } diff --git a/app/assets/javascripts/discourse/templates/user/activity.hbs b/app/assets/javascripts/discourse/templates/user/activity.hbs index 4d4ed6ce3f..f1818f2ec9 100644 --- a/app/assets/javascripts/discourse/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/templates/user/activity.hbs @@ -28,7 +28,7 @@ {{#if viewingSelf}}
- {{d-button action="exportUserArchive" label="user.download_archive" icon="download"}} + {{d-button action="exportUserArchive" label="user.download_archive.button_text" icon="download"}}
{{/if}} {{/d-section}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1ec5a52c01..e65c6f928c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -487,8 +487,11 @@ en: profile: "Profile" mute: "Mute" edit: "Edit Preferences" - download_archive: "Download My Posts" - download_archive_confirm: "Are you sure you want to download your posts?" + download_archive: + button_text: "Download My Posts" + confirm: "Are you sure you want to download your posts?" + success: "Download initiated, you will be notified via message when the process is complete." + rate_limit_error: "Posts can be downloaded once per day, please try again tomorrow." new_private_message: "New Message" private_message: "Message" private_messages: "Messages" @@ -2636,7 +2639,6 @@ en: export_csv: success: "Export initiated, you will be notified via message when the process is complete." failed: "Export failed. Please check the logs." - rate_limit_error: "Posts can be downloaded once per day, please try again tomorrow." button_text: "Export" button_title: user: "Export full user list in CSV format." From 943e605add6c52e9ed3459266522ae1bfe42c1e1 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 15:20:46 +0800 Subject: [PATCH 014/122] REFACTOR: Project's convention is to use dash for classes. --- app/assets/javascripts/admin/templates/group.hbs | 10 +++++----- app/assets/stylesheets/common/admin/admin_base.scss | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 6dfd5f6a56..3e176f5a92 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -101,8 +101,8 @@ {{/unless}} {{#unless model.automatic}} -
-
+
+
{{text-field name="flair_url" value=model.flair_url placeholderKey="admin.groups.flair_url_placeholder"}} @@ -110,13 +110,13 @@
- {{text-field name="flair_bg_color" class="flair_bg_color" value=model.flair_bg_color placeholderKey="admin.groups.flair_bg_color_placeholder"}} + {{text-field name="flair_bg_color" class="flair-bg-color" value=model.flair_bg_color placeholderKey="admin.groups.flair_bg_color_placeholder"}}
{{#if flairPreviewIcon}}
- {{text-field name="flair_color" class="flair_color" value=model.flair_color placeholderKey="admin.groups.flair_color_placeholder"}} + {{text-field name="flair_color" class="flair-color" value=model.flair_color placeholderKey="admin.groups.flair_color_placeholder"}}
{{/if}} @@ -127,7 +127,7 @@
{{#if flairPreviewIcon}} -
+
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index cb7bb105ce..4a93fedb9a 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -705,11 +705,11 @@ section.details { } } .form-horizontal { - .flair_inputs { + .flair-inputs { margin-top: 30px; margin-bottom: 30px; - .flair_left { + .flair-left { float: left; width: 60%; input[name=flair_url] { @@ -717,7 +717,7 @@ section.details { } } - .flair_right { + .flair-right { float: left; margin-left: 30px; } @@ -725,7 +725,7 @@ section.details { } } .row.groups { - input[type='text'].flair_bg_color, input[type='text'].flair_color { + input[type='text'].flair-bg-color, input[type='text'].flair-color { width: 200px; } } From e97efe3ac6e066559907a8b9eb1833e5a122decd Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 15:31:03 +0800 Subject: [PATCH 015/122] Fix incorrect class and removal unncessary `div`. --- .../javascripts/admin/templates/group.hbs | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 3e176f5a92..0d53645023 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -128,30 +128,26 @@ {{#if flairPreviewIcon}}
-
- -
-
- -
-
- -
+ +
+
+ +
+
+
{{/if}} {{#if flairPreviewImage}} -
-
- -
-
- -
-
+
+ +
+
+
+
{{/if}} From 06d501ad414c487b73958c3dc514caed5e8229e8 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 15:32:53 +0800 Subject: [PATCH 016/122] Fix incorrect style for avatar flair icon preview. --- app/assets/stylesheets/common/base/groups.scss | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss index 15120e7242..9146b4bc07 100644 --- a/app/assets/stylesheets/common/base/groups.scss +++ b/app/assets/stylesheets/common/base/groups.scss @@ -3,15 +3,17 @@ display: table; } - .avatar-flair { - $size: 40px; + .group-avatar-flair { + .avatar-flair { + $size: 40px; - background-size: $size; - height: $size; - width: $size; + background-size: $size; + height: $size; + width: $size; - i { - font-size: $size !important; + i { + font-size: $size !important; + } } } From 6725464d3134da5ff110bcd328cee424836f30b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 29 Nov 2016 15:46:10 +0100 Subject: [PATCH 017/122] bump onebox --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 64bf4bc6d2..32c6109153 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,7 +212,7 @@ GEM omniauth-twitter (1.2.1) json (~> 1.3) omniauth-oauth (~> 1.1) - onebox (1.6.0) + onebox (1.6.1) htmlentities (~> 4.3.4) moneta (~> 0.8) multi_json (~> 1.11) From 4e251eaf080cb3d77eca843a47a8a950060d2564 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 29 Nov 2016 13:07:53 -0500 Subject: [PATCH 018/122] FIX: Support overwriting nested resources --- .../admin/routes/admin-route-map.js.es6 | 10 +- .../discourse/mapping-router.js.es6 | 149 +++++++++++------- 2 files changed, 101 insertions(+), 58 deletions(-) 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 9b1b2c72c2..f60f422e02 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,7 +1,5 @@ -export default { - resource: 'admin', - - map() { +export default function() { + this.route('admin', { resetNamespace: true }, function() { this.route('dashboard', { path: '/' }); this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() { this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} ); @@ -84,5 +82,7 @@ export default { this.route('adminBadges', { path: '/badges', resetNamespace: true }, function() { this.route('show', { path: '/:badge_id' }); }); - } + + this.route('adminPlugins', { path: '/plugins', resetNamespace: true }); + }); }; diff --git a/app/assets/javascripts/discourse/mapping-router.js.es6 b/app/assets/javascripts/discourse/mapping-router.js.es6 index 306910624c..1e7c493332 100644 --- a/app/assets/javascripts/discourse/mapping-router.js.es6 +++ b/app/assets/javascripts/discourse/mapping-router.js.es6 @@ -5,11 +5,92 @@ const BareRouter = Ember.Router.extend({ location: Ember.testing ? 'none': 'discourse-location' }); -export function mapRoutes() { +// Ember's router can't be extended. We need to allow plugins to add routes to routes that were defined +// in the core app. This class has the same API as Ember's `Router.map` but saves the results in a tree. +// The tree is applied after all plugins are defined. +class RouteNode { + constructor(name, opts={}, depth=0) { + this.name = name; + this.opts = opts; + this.depth = depth; + this.children = []; + this.childrenByName = {}; + this.paths = {}; - var Router = BareRouter.extend(); - const resources = {}; - const paths = {}; + if (opts.path) { + this.paths[opts.path] = true; + } + } + + route(name, opts, fn) { + if (typeof opts === 'function') { + fn = opts; + opts = {}; + } else { + opts = opts || {}; + } + + const existing = this.childrenByName[name]; + if (existing) { + if (opts.path) { + existing.paths[opts.path] = true; + } + existing.extract(fn); + } else { + const node = new RouteNode(name, opts, this.depth+1); + node.extract(fn); + this.childrenByName[name] = node; + this.children.push(node); + } + } + + extract(fn) { + if (!fn) { return; } + fn.call(this); + } + + mapRoutes(router) { + const children = this.children; + if (this.name === 'root') { + children.forEach(c => c.mapRoutes(router)); + } else { + + const builder = (children.length === 0) ? undefined : function() { + children.forEach(c => c.mapRoutes(this)); + }; + router.route(this.name, this.opts, builder); + + // We can have multiple paths to the same route + const paths = Object.keys(this.paths); + if (paths.length > 1) { + paths.filter(p => p !== this.opts.path).forEach(path => { + const newOpts = jQuery.extend({}, this.opts, { path }); + router.route(this.name, newOpts, builder); + }); + } + } + } + + findSegment(segments) { + if (segments && segments.length) { + const first = segments.shift(); + const node = this.childrenByName[first]; + if (node) { + return (segments.length === 0) ? node : node.findSegment(segments); + } + } + } + + findPath(path) { + if (path) { + return this.findSegment(path.split('.')); + } + } +} + +export function mapRoutes() { + const tree = new RouteNode('root'); + const extras = []; // 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 @@ -20,62 +101,24 @@ export function mapRoutes() { var module = require(key, null, null, true); if (!module || !module.default) { throw new Error(key + ' must export a route map.'); } - var mapObj = module.default; + const mapObj = module.default; if (typeof mapObj === 'function') { - mapObj = { resource: 'root', map: mapObj }; + tree.extract(mapObj); + } else { + extras.push(mapObj); } - - if (!resources[mapObj.resource]) { resources[mapObj.resource] = []; } - resources[mapObj.resource].push(mapObj.map); - if (mapObj.path) { paths[mapObj.resource] = mapObj.path; } } }); - return 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; + extras.forEach(extra => { + const node = tree.findPath(extra.resource); + if (node) { + node.extract(extra.map); } + }); - // Even if no plugins set it up, we need an `adminPlugins` route - var adminPlugins = 'admin.adminPlugins'; - resources[adminPlugins] = resources[adminPlugins] || [Ember.K]; - paths[adminPlugins] = paths[adminPlugins] || "/plugins"; - - var segments = {}, - standalone = []; - - Object.keys(resources).forEach(function(r) { - var m = /^([^\.]+)\.(.*)$/.exec(r); - if (m) { - segments[m[1]] = m[2]; - } else { - standalone.push(r); - } - }); - - // Apply other resources next. A little hacky but works! - standalone.forEach(function(r) { - router.route(r, {path: paths[r], resetNamespace: true}, function() { - var res = this; - resources[r].forEach(function(m) { m.call(res); }); - - var s = segments[r]; - if (s) { - var full = r + '.' + s; - res.route(s, {path: paths[full], resetNamespace: true}, function() { - var nestedRes = this; - resources[full].forEach(function(m) { m.call(nestedRes); }); - }); - } - }); - }); - + return BareRouter.extend().map(function() { + tree.mapRoutes(this); this.route('unknown', {path: '*path'}); }); } From ae38a78bb6c8562692a9a854ea9aade8b0998615 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 29 Nov 2016 15:54:28 -0500 Subject: [PATCH 019/122] FIX: Categories default page was broken --- app/assets/javascripts/discourse/mapping-router.js.es6 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/mapping-router.js.es6 b/app/assets/javascripts/discourse/mapping-router.js.es6 index 1e7c493332..31d7c2c2ea 100644 --- a/app/assets/javascripts/discourse/mapping-router.js.es6 +++ b/app/assets/javascripts/discourse/mapping-router.js.es6 @@ -17,9 +17,11 @@ class RouteNode { this.childrenByName = {}; this.paths = {}; - if (opts.path) { - this.paths[opts.path] = true; + if (!opts.path) { + opts.path = name; } + + this.paths[opts.path] = true; } route(name, opts, fn) { From a18793212654b4f9af5ce1e9dc89269d474db334 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 29 Nov 2016 17:10:25 -0500 Subject: [PATCH 020/122] Counts at top of summary email are links --- app/mailers/user_notifications.rb | 20 +++++++++++++++----- app/views/user_notifications/digest.html.erb | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index a41e83c584..76bf33beac 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -102,21 +102,31 @@ class UserNotifications < ActionMailer::Base @preheader_text = I18n.t('user_notifications.digest.preheader', last_seen_at: @last_seen_at) # Try to find 3 interesting stats for the top of the digest - @counts = [{label_key: 'user_notifications.digest.new_topics', value: Topic.new_since_last_seen(user, min_date).count}] + @counts = [{label_key: 'user_notifications.digest.new_topics', + value: Topic.new_since_last_seen(user, min_date).count, + href: "#{Discourse.base_url}/new"}] value = user.unread_notifications - @counts << {label_key: 'user_notifications.digest.unread_notifications', value: value} if value > 0 + @counts << {label_key: 'user_notifications.digest.unread_notifications', value: value, href: "#{Discourse.base_url}/my/notifications"} if value > 0 value = user.unread_private_messages - @counts << {label_key: 'user_notifications.digest.unread_messages', value: value} if value > 0 + @counts << {label_key: 'user_notifications.digest.unread_messages', value: value, href: "#{Discourse.base_url}/my/messages"} if value > 0 if @counts.size < 3 - @counts << {label_key: 'user_notifications.digest.new_posts', value: Post.for_mailing_list(user, min_date).where("posts.post_number > ?", 1).count} + @counts << { + label_key: 'user_notifications.digest.new_posts', + value: Post.for_mailing_list(user, min_date).where("posts.post_number > ?", 1).count, + href: "#{Discourse.base_url}/new" + } end if @counts.size < 3 value = User.real.where(active: true, staged: false).not_suspended.where("created_at > ?", min_date).count - @counts << {label_key: 'user_notifications.digest.new_users', value: value } if value > 0 + @counts << { + label_key: 'user_notifications.digest.new_users', + value: value, + href: "#{Discourse.base_url}/about" + } if value > 0 end # Now fetch some topics and posts to show diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index d8819a9da7..a762f961da 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -63,14 +63,14 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%- @counts.each do |count| -%> - <%= count[:value] -%> + <%= count[:value] -%> <%- end -%> <%- @counts.each do |count| -%> - <%=t count[:label_key] -%> + <%=t count[:label_key] -%> <%- end -%> From 4d5268226791134fc92a4b0eabe619321cdad9a1 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 29 Nov 2016 17:12:11 -0500 Subject: [PATCH 021/122] FIX: wrong font of site title in summary email --- app/views/user_notifications/digest.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index a762f961da..6390b87b9f 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -20,7 +20,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo - <%- if show_image_with_url(t.image_url) -%> - <%- end -%> @@ -147,7 +147,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
+ <%- if logo_url.blank? %> From 3f3a0d7b14447e319df732936b4e26a3bdde92e4 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Tue, 29 Nov 2016 17:21:51 -0800 Subject: [PATCH 022/122] make counts fixed width for digest/summary --- app/views/user_notifications/digest.html.erb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 6390b87b9f..9bb24e9df1 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -157,24 +157,24 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo - - + - - - From 2ffb42a055bbb9a8b9e68920f2b7f001fec59d53 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:30:58 +0800 Subject: [PATCH 023/122] Fix qunit tests on Travis. --- vendor/assets/javascripts/run-qunit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/assets/javascripts/run-qunit.js b/vendor/assets/javascripts/run-qunit.js index 97fce3dc94..7f0335fea5 100644 --- a/vendor/assets/javascripts/run-qunit.js +++ b/vendor/assets/javascripts/run-qunit.js @@ -36,7 +36,7 @@ page.open(args[0], function(status) { } else { page.evaluate(logQUnit); - var timeout = parseInt(args[1] || 130000, 10), + var timeout = parseInt(args[1] || 200000, 10), start = Date.now(); var interval = setInterval(function() { From 5200446eb7626b9dcc946c6cde9a5ce2cf13dd61 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:35:44 +0800 Subject: [PATCH 024/122] Increase Qunit tests timeout on Travis. --- .travis.yml | 2 +- lib/tasks/qunit.rake | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 513f3c15ad..b999ae5100 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,4 +55,4 @@ install: - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails rails-observers seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" -script: 'bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test' +script: 'bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test['200000']' diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake index 70ff6d5e45..b4e9e22887 100644 --- a/lib/tasks/qunit.rake +++ b/lib/tasks/qunit.rake @@ -1,6 +1,6 @@ desc "Runs the qunit test suite" -task "qunit:test" => :environment do +task "qunit:test", [:timeout] => :environment do |_, args| require "rack" require "socket" @@ -35,7 +35,7 @@ task "qunit:test" => :environment do begin success = true test_path = "#{Rails.root}/vendor/assets/javascripts" - cmd = "phantomjs #{test_path}/run-qunit.js http://localhost:#{port}/qunit" + cmd = "phantomjs #{test_path}/run-qunit.js http://localhost:#{port}/qunit #{args[:timeout]}" options = {} From f794c25f608ecacabf0388da3bccdb32126f1fb2 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:38:21 +0800 Subject: [PATCH 025/122] FIX: Ensure a Thread is always running. --- .../connection_adapters/postgresql_fallback_adapter.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb index 03932d6cc7..c901213ee7 100644 --- a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb +++ b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb @@ -101,6 +101,8 @@ module ActiveRecord config = config.symbolize_keys if fallback_handler.master_down? + fallback_handler.verify_master + connection = postgresql_connection(config.dup.merge({ host: config[:replica_host], port: config[:replica_port] })) From 1e7de826dce70e43dd2e26996446ba0cd70fa25e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:39:31 +0800 Subject: [PATCH 026/122] FIX: Remove unused code. --- .../connection_adapters/postgresql_fallback_adapter.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb index c901213ee7..2f2e75030c 100644 --- a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb +++ b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb @@ -39,10 +39,6 @@ class PostgreSQLFallbackHandler synchronize { @masters_down.delete(namespace) } end - def running? - synchronize { @thread.alive? } - end - def initiate_fallback_to_master @masters_down.keys.each do |key| RailsMultisite::ConnectionManagement.with_connection(key) do @@ -59,7 +55,6 @@ class PostgreSQLFallbackHandler Discourse.disable_readonly_mode end rescue => e - byebug logger.warn "#{log_prefix}: Connection to master PostgreSQL server failed with '#{e.message}'" end end From e96487283e78f48c5f0aa77a3455468a6e811837 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:41:12 +0800 Subject: [PATCH 027/122] Fix travis script. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b999ae5100..0f46f1ebca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,4 +55,4 @@ install: - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails rails-observers seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" -script: 'bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test['200000']' +script: "bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test['200000']" From 256a231a0a4103cb7a8004e569f2ddb459bec9b6 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:59:22 +0800 Subject: [PATCH 028/122] FIX: Incorrect translation key. --- .../discourse/routes/user-activity-likes-given.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 index 852f67ab27..89bbbc46b2 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 @@ -3,5 +3,5 @@ import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ userActionType: UserAction.TYPES["likes_given"], - noContentHelpKey: 'no_likes_given' + noContentHelpKey: 'user_activity.no_likes_given' }); From a97c59ed2e75689a49b46dc266cb9977922baf57 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 30 Nov 2016 14:36:42 +0530 Subject: [PATCH 029/122] fix: adminPlugins:index route missing --- app/assets/javascripts/admin/routes/admin-route-map.js.es6 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f60f422e02..bd38784bb7 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -83,6 +83,8 @@ export default function() { this.route('show', { path: '/:badge_id' }); }); - this.route('adminPlugins', { path: '/plugins', resetNamespace: true }); + this.route('adminPlugins', { path: '/plugins', resetNamespace: true }, function() { + this.route('index', { path: '/' }); + }); }); }; From 7e5121cbd3c766647e1045a8a10ae0ec92015414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 11:45:02 +0100 Subject: [PATCH 030/122] Add 'x-vcard' content-type to default email attachment blacklist --- config/site_settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 9be481803e..b6f233ebc7 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -669,7 +669,7 @@ email: reset_bounce_score_after_days: 30 attachment_content_type_blacklist: type: list - default: "pkcs7" + default: "pkcs7|x-vcard" attachment_filename_blacklist: type: list default: "smime.p7s|signature.asc" From 3cc6fabb62e3438a1244add88f3bde63f876924f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 16:29:36 +0100 Subject: [PATCH 031/122] bump onebox --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 32c6109153..f48e3ff72e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,7 +212,7 @@ GEM omniauth-twitter (1.2.1) json (~> 1.3) omniauth-oauth (~> 1.1) - onebox (1.6.1) + onebox (1.6.2) htmlentities (~> 4.3.4) moneta (~> 0.8) multi_json (~> 1.11) From dec8a861f057e267afaa7876f856c4e29385ff49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 17:18:34 +0100 Subject: [PATCH 032/122] FIX: don't raise exception when a quote was already extracted --- app/models/quoted_post.rb | 30 +++++++++++++++--------------- spec/models/quoted_post_spec.rb | 12 +++++++++--- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/app/models/quoted_post.rb b/app/models/quoted_post.rb index 5a4ad07673..2bd06fce81 100644 --- a/app/models/quoted_post.rb +++ b/app/models/quoted_post.rb @@ -20,22 +20,22 @@ class QuotedPost < ActiveRecord::Base next if uniq[[topic_id,post_number]] uniq[[topic_id,post_number]] = true + begin + # It would be so much nicer if we used post_id in quotes + results = exec_sql "INSERT INTO quoted_posts(post_id, quoted_post_id, created_at, updated_at) + SELECT :post_id, p.id, current_timestamp, current_timestamp + FROM posts p + LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id + WHERE post_number = :post_number AND + topic_id = :topic_id AND + q.id IS NULL + RETURNING quoted_post_id + ", post_id: post.id, post_number: post_number, topic_id: topic_id - # It would be so much nicer if we used post_id in quotes - results = exec_sql "INSERT INTO quoted_posts(post_id, quoted_post_id, created_at, updated_at) - SELECT :post_id, p.id, current_timestamp, current_timestamp - FROM posts p - LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id - WHERE post_number = :post_number AND - topic_id = :topic_id AND - q.id IS NULL - RETURNING quoted_post_id - ", post_id: post.id, post_number: post_number, topic_id: topic_id - - results = results.to_a - - if results.length > 0 - ids << results[0]["quoted_post_id"].to_i + results = results.to_a + ids << results[0]["quoted_post_id"].to_i if results.length > 0 + rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation + # it's fine end end diff --git a/spec/models/quoted_post_spec.rb b/spec/models/quoted_post_spec.rb index 77bb741339..4c61952c69 100644 --- a/spec/models/quoted_post_spec.rb +++ b/spec/models/quoted_post_spec.rb @@ -15,9 +15,15 @@ describe QuotedPost do post1 = Fabricate(:post) post2 = Fabricate(:post) - post2.cooked = <
techAPJ:

When the user will v

-HTML + post2.cooked = <<-HTML + + HTML QuotedPost.create!(post_id: post2.id, quoted_post_id: 999) From 57d03698941f2a2ff8ae3435bed7e2c969af4ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 18:05:59 +0100 Subject: [PATCH 033/122] FIX: don't raise an exception when a link was already extracted --- app/models/topic_link.rb | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index 182c3d64d9..ddeac934d7 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -37,7 +37,7 @@ class TopicLink < ActiveRecord::Base def self.topic_map(guardian, topic_id) # Sam: complicated reports are really hard in AR - builder = SqlBuilder.new < Date: Wed, 30 Nov 2016 13:16:24 -0500 Subject: [PATCH 034/122] FIX: error reporting from SystemMessage.create --- lib/system_message.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/system_message.rb b/lib/system_message.rb index 98280b5e9c..d95cbf00be 100644 --- a/lib/system_message.rb +++ b/lib/system_message.rb @@ -33,7 +33,7 @@ class SystemMessage post = creator.create if creator.errors.present? - raise StandardError, creator.errors.to_s + raise StandardError, creator.errors.full_messages.join(" ") end UserArchivedMessage.create!(user: Discourse.site_contact_user, topic: post.topic) From 142d35a0a53afaedbc6c7ed144639b869e343a42 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 30 Nov 2016 13:45:25 -0500 Subject: [PATCH 035/122] Minor Discourse Fixes --- .../javascripts/discourse/components/topic-status.js.es6 | 6 ++---- .../discourse/controllers/discovery/categories.js.es6 | 2 +- .../javascripts/discourse/controllers/preferences.js.es6 | 4 +--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/components/topic-status.js.es6 b/app/assets/javascripts/discourse/components/topic-status.js.es6 index 5afba3db76..bede7d57a6 100644 --- a/app/assets/javascripts/discourse/components/topic-status.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-status.js.es6 @@ -27,8 +27,6 @@ export default Ember.Component.extend(bufferedRender({ }.property('disableActions'), buildBuffer(buffer) { - const self = this; - const renderIcon = function(name, key, actionable) { const title = escapeExpression(I18n.t(`topic_statuses.${key}.help`)), startTag = actionable ? "a href" : "span", @@ -39,8 +37,8 @@ export default Ember.Component.extend(bufferedRender({ buffer.push(`<${startTag} title='${title}' class='topic-status'>${icon}`); }; - const renderIconIf = function(conditionProp, name, key, actionable) { - if (!self.get(conditionProp)) { return; } + const renderIconIf = (conditionProp, name, key, actionable) => { + if (!this.get(conditionProp)) { return; } renderIcon(name, key, actionable); }; diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index 5b435b8d9a..19d6ae6b80 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -12,7 +12,7 @@ export default DiscoveryController.extend({ return Discourse.User.currentProp('staff'); }, - @computed("model.categories.@each.featuredTopics.length") + @computed("model.categories.[].featuredTopics.length") latestTopicOnly() { return this.get("model.categories").find(c => c.get("featuredTopics.length") > 1) === undefined; }, diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 4e96336563..5ee945d4da 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -157,9 +157,7 @@ export default Ember.Controller.extend(CanCheckEmails, { // Cook the bio for preview model.set('name', this.get('newNameInput')); - var options = {}; - - return model.save(options).then(() => { + return model.save().then(() => { if (Discourse.User.currentProp('id') === model.get('id')) { Discourse.User.currentProp('name', model.get('name')); } From 8c8549b27bb08b792b12feba761acb4f993013e2 Mon Sep 17 00:00:00 2001 From: Erick Guan Date: Fri, 4 Nov 2016 21:16:30 +0100 Subject: [PATCH 036/122] FIX: missing post and topic edited webhooks --- app/models/web_hook.rb | 20 +++++++++++--------- lib/post_revisor.rb | 1 + spec/models/web_hook_spec.rb | 8 ++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 42bbfd5b1e..289938329e 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -40,10 +40,14 @@ class WebHook < ActiveRecord::Base end end - def self.enqueue_topic_hooks(event, topic, user) + def self.enqueue_topic_hooks(event, topic, user=nil) WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category&.id, event_name: event.to_s) end + def self.enqueue_post_hooks(event, post, user=nil) + WebHook.enqueue_hooks(:post, post_id: post.id, topic_id: post&.topic&.id, user_id: user&.id, category_id: post&.topic&.category_id, event_name: event.to_s) + end + %i(topic_destroyed topic_recovered).each do |event| DiscourseEvent.on(event) do |topic, user| WebHook.enqueue_topic_hooks(event, topic, user) @@ -57,18 +61,16 @@ class WebHook < ActiveRecord::Base %i(post_created post_destroyed post_recovered).each do |event| - DiscourseEvent.on(event) do |post, _, user| - WebHook.enqueue_hooks(:post, - post_id: post.id, - topic_id: post&.topic&.id, - user_id: user&.id, - category_id: post.topic&.category&.id, - event_name: event.to_s - ) + WebHook.enqueue_post_hooks(event, post, user) end end + DiscourseEvent.on(:post_edited) do |post, topic_changed| + WebHook.enqueue_post_hooks(:post_edited, post) + WebHook.enqueue_topic_hooks(:topic_edited, post.topic) if post.is_first_post? && topic_changed + end + %i(user_created user_approved).each do |event| DiscourseEvent.on(event) do |user| WebHook.enqueue_hooks(:user, user_id: user.id, event_name: event.to_s) diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index b9cccc9f9d..f253d3712e 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -446,6 +446,7 @@ class PostRevisor def post_process_post @post.invalidate_oneboxes = true @post.trigger_post_process + DiscourseEvent.trigger(:post_edited, @post, self.topic_changed?) end def update_topic_word_counts diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 88eaee6cb0..0022c0e9d6 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -120,14 +120,14 @@ describe WebHook do end it 'should enqueue the right hooks for post events' do - user # bypass a user_created event - WebHook.expects(:enqueue_hooks).once + WebHook.expects(:enqueue_post_hooks).once PostCreator.create(user, { raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true }) - WebHook.expects(:enqueue_hooks).once + # post destroy or recover triggers a moderator post + WebHook.expects(:enqueue_post_hooks).twice PostDestroyer.new(user, post2).destroy - WebHook.expects(:enqueue_hooks).once + WebHook.expects(:enqueue_post_hooks).twice PostDestroyer.new(user, post2).recover end From 28b7ef7142135e9596989d7d095db517821eafe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 22:59:58 +0100 Subject: [PATCH 037/122] FIX: rendering multiple polls in the same post was broken --- .../poll/assets/javascripts/initializers/extend-for-poll.js.es6 | 2 +- plugins/poll/assets/javascripts/lib/md5.js.es6 | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 plugins/poll/assets/javascripts/lib/md5.js.es6 diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index 9631c3ad99..ee9904c014 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -89,7 +89,7 @@ function initializePolls(api) { ); $poll.replaceWith($div); - Em.run.schedule('afterRender', () => pollComponent.renderer.replaceIn(pollComponent, $div[0])); + Em.run.schedule('afterRender', () => pollComponent.renderer.appendTo(pollComponent, $div[0])); postPollViews[pollId] = pollComponent; }); diff --git a/plugins/poll/assets/javascripts/lib/md5.js.es6 b/plugins/poll/assets/javascripts/lib/md5.js.es6 deleted file mode 100644 index 8b13789179..0000000000 --- a/plugins/poll/assets/javascripts/lib/md5.js.es6 +++ /dev/null @@ -1 +0,0 @@ - From 453d1491827e34500488f8cb704dd8ed5f9e4acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 30 Nov 2016 23:41:07 +0100 Subject: [PATCH 038/122] just checking for existence is enough here --- app/models/topic_link.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index ddeac934d7..12ae7dcaab 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -163,7 +163,7 @@ SQL added_urls << url - unless TopicLink.find_by(topic_id: post.topic_id, post_id: post.id, url: url) + unless TopicLink.exists?(topic_id: post.topic_id, post_id: post.id, url: url) begin TopicLink.create!(post_id: post.id, user_id: post.user_id, From 8cd30cdc8b9bd7c502b67f7b38a65ac55d7dd632 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 30 Nov 2016 17:57:57 -0500 Subject: [PATCH 039/122] FIX: respect the enable_names setting, and fix cases when html is invalid --- app/views/user_notifications/digest.html.erb | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 9bb24e9df1..c64f48b6ca 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -130,7 +130,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
@@ -219,7 +219,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
+

<%= t.like_count -%>

+

<%= t.posts_count - 1 -%>

+ <% t.posters_summary.each do |ps| %> <% if ps.user %> <% end %> <% end %> - + + <%=t 'user_notifications.digest.join_the_discussion' %>
<%= t.user.try(:username) -%>
- <% if t.user.try(:name).present? %> + <% if SiteSetting.enable_names? && t.user.try(:name).present? %>

<%= t.user.name -%>

<% end %>
@@ -261,8 +261,10 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo + + +
-

+

<%=t 'user_notifications.digest.popular_posts' %>

-
<%= post.user.username -%>
-

<%= post.user.name -%>

+
<%= post.user.try(:username) -%>
+ <% if SiteSetting.enable_names? && post.user.try(:name) %> +

<%= post.user.name -%>

+ <% end %>

@@ -279,23 +281,33 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo

- +
 
 
<% end %> +
 
+ <% end %> <% if @other_new_for_you.present? %> -
<%=t 'user_notifications.digest.more_new' %>
+
<%=t 'user_notifications.digest.more_new' %>
<%= digest_custom_html("above_popular_topics") %> + + + + + + +
  + @@ -342,6 +354,11 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
+
 
+ <%= digest_custom_html("below_popular_topics") %> <% end %> From 309e37f0e673d3557178bb70792f66a2abd1ab91 Mon Sep 17 00:00:00 2001 From: cpradio Date: Wed, 30 Nov 2016 19:08:13 -0500 Subject: [PATCH 040/122] UX: Remove !important from header .discourse-tag per https://meta.discourse.org/t/planned-tag-color-issue-when-scrolled-down/53582/4?u=cpradio --- app/assets/stylesheets/common/base/tagging.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 332614c4ac..70b971c422 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -136,7 +136,7 @@ $tag-color: scale-color($primary, $lightness: 40%); top: -0.1em; } -header .discourse-tag {color: $tag-color !important; } +header .discourse-tag {color: $tag-color } .list-tags { display: inline; From 61bcc98c0ed0c1f1dd937c267259b50993f23d52 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Wed, 30 Nov 2016 17:13:01 -0800 Subject: [PATCH 041/122] slightly tighten spacing on digests --- app/views/user_notifications/digest.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index c64f48b6ca..04afc7056b 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -135,7 +135,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <% end %>
+
- From 6b4ef86ac4823e99f6917498fc121d176e8ae45b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 1 Dec 2016 10:57:12 +0800 Subject: [PATCH 042/122] Add acceptance test for rendering polls. --- .../javascripts/acceptance/polls-test.js.es6 | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 plugins/poll/test/javascripts/acceptance/polls-test.js.es6 diff --git a/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 new file mode 100644 index 0000000000..401838b610 --- /dev/null +++ b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 @@ -0,0 +1,32 @@ +import { acceptance, controllerFor } from "helpers/qunit-helpers"; +import PostCooked from 'discourse/widgets/post-cooked'; + +acceptance("Rendering polls", { + loggedIn: true, + settings: { poll_enabled: true }, + setup() { + const response = object => { + return [ + 200, + { "Content-Type": "application/json" }, + object + ]; + } + + server.get('/t/13.json', () => { + return response({"post_stream":{"posts":[{"id":19,"name":null,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","created_at":"2016-12-01T02:39:49.199Z","cooked":"
\n
\n
    \n
  • test
  • \n
  • haha
  • \n
\n

0voters

\n
\n\n
\n\n
\n
\n
    \n
  • donkey
  • \n
  • kong
  • \n
\n

0voters

\n
\n\n
","post_number":1,"post_type":1,"updated_at":"2016-12-01T02:47:18.317Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":13,"topic_slug":"this-is-a-test-topic-for-polls","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":2,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"57ddd734344eb7436d64a7d68a0df444","html":"test","votes":0},{"id":"b5b78d79ab5b5d75d4d33d8b87f5d2aa","html":"haha","votes":0}],"voters":2,"status":"open","name":"poll"},"test":{"options":[{"id":"c26ad90783b0d80936e5fdb292b7963c","html":"donkey","votes":0},{"id":"99f2b9ac452ba73b115fcf3556e6d2d4","html":"kong","votes":0}],"voters":3,"status":"open","name":"test"}}}],"stream":[19]},"timeline_lookup":[[1,0]],"id":13,"title":"This is a test topic for polls","fancy_title":"This is a test topic for polls","posts_count":1,"created_at":"2016-12-01T02:39:48.055Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2016-12-01T02:39:49.199Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic-for-polls","category_id":1,"word_count":10,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_13","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","post_count":1}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T02:10:54.328Z","last_posted_at":"2016-11-24T02:10:54.393Z","bumped":true,"bumped_at":"2016-11-24T02:10:54.393Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/letter_avatar_proxy/v2/letter/s/bcef8e/{size}.png"}}]},{"id":12,"title":"Some testing topic testing","fancy_title":"Some testing topic testing","slug":"some-testing-topic-testing","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2016-11-24T08:36:08.773Z","last_posted_at":"2016-12-01T01:15:52.008Z","bumped":true,"bumped_at":"2016-12-01T01:15:52.008Z","unseen":false,"last_read_post_number":4,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]},{"id":11,"title":"Some testing topic","fancy_title":"Some testing topic","slug":"some-testing-topic","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T08:35:26.758Z","last_posted_at":"2016-11-24T08:35:26.894Z","bumped":true,"bumped_at":"2016-11-24T08:35:26.894Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":19,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false}); + }); + } +}); + +test("Single Poll", () => { + visit("/t/this-is-a-test-topic-for-polls/13"); + + andThen(() => { + const polls = find('.poll'); + + equal(polls.length, 2, 'it should render the polls correctly'); + equal(find('.info-number', polls[0]).text(), '2', 'it should display the right number of votes'); + equal(find('.info-number', polls[1]).text(), '3', 'it should display the right number of votes'); + }); +}); From 0dbcb4ec8add06e59c26ebaddb4df2a2e9848809 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 1 Dec 2016 11:24:30 -0500 Subject: [PATCH 043/122] FIX: `lookupFactory` doesn't exist on `getOwner` result --- app/assets/javascripts/discourse/routes/application.js.es6 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 187199ff53..1c3fed9a6b 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -146,10 +146,9 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, { }, changeBulkTemplate(w) { - const controllerName = w.replace('modal/', ''), - factory = getOwner(this).lookupFactory('controller:' + controllerName); - - this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: factory ? controllerName : 'topic-bulk-actions'}); + const controllerName = w.replace('modal/', ''); + const controller = getOwner(this).lookup('controller:' + controllerName); + this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: controller ? controllerName : 'topic-bulk-actions'}); }, createNewTopicViaParams(title, body, category_id, category, tags) { From 69ff0e48b4b50ad3df78dcb4f52915eef6850de9 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 1 Dec 2016 11:33:33 -0500 Subject: [PATCH 044/122] Remove `SortedMixin` --- .../admin/controllers/admin-user-fields.js.es6 | 16 ++++++---------- .../javascripts/admin/templates/user-fields.hbs | 6 +++--- .../controllers/reorder-categories.js.es6 | 10 ++-------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 index 70963a147e..6b106b075d 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 @@ -6,12 +6,8 @@ export default Ember.Controller.extend({ fieldTypes: null, createDisabled: Em.computed.gte('model.length', MAX_FIELDS), - arrangedContent: function() { - return Ember.ArrayProxy.extend(Ember.SortableMixin).create({ - sortProperties: ['position'], - content: this.get('model') - }); - }.property('model'), + fieldSortOrder: ['position'], + sortedFields: Ember.computed.sort('model', 'fieldSortOrder'), actions: { createField() { @@ -20,9 +16,9 @@ export default Ember.Controller.extend({ }, moveUp(f) { - const idx = this.get('arrangedContent').indexOf(f); + const idx = this.get('sortedFields').indexOf(f); if (idx) { - const prev = this.get('arrangedContent').objectAt(idx-1); + const prev = this.get('sortedFields').objectAt(idx-1); const prevPos = prev.get('position'); prev.update({ position: f.get('position') }); @@ -31,9 +27,9 @@ export default Ember.Controller.extend({ }, moveDown(f) { - const idx = this.get('arrangedContent').indexOf(f); + const idx = this.get('sortedFields').indexOf(f); if (idx > -1) { - const next = this.get('arrangedContent').objectAt(idx+1); + const next = this.get('sortedFields').objectAt(idx+1); const nextPos = next.get('position'); next.update({ position: f.get('position') }); diff --git a/app/assets/javascripts/admin/templates/user-fields.hbs b/app/assets/javascripts/admin/templates/user-fields.hbs index 3a89a0e06e..72e4933052 100644 --- a/app/assets/javascripts/admin/templates/user-fields.hbs +++ b/app/assets/javascripts/admin/templates/user-fields.hbs @@ -4,11 +4,11 @@

{{i18n 'admin.user_fields.help'}}

{{#if model}} - {{#each arrangedContent as |uf|}} + {{#each sortedFields as |uf|}} {{admin-user-field-item userField=uf fieldTypes=fieldTypes - firstField=arrangedContent.firstObject - lastField=arrangedContent.lastObject + firstField=sortedFields.firstObject + lastField=sortedFields.lastObject destroyAction="destroy" moveUpAction="moveUp" moveDownAction="moveDown"}} diff --git a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 index 18a000a388..c975eb5bfb 100644 --- a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 @@ -5,8 +5,6 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { on, default as computed } from "ember-addons/ember-computed-decorators"; import Ember from 'ember'; -const SortableArrayProxy = Ember.ArrayProxy.extend(Ember.SortableMixin); - export default Ember.Controller.extend(ModalFunctionality, Ember.Evented, { @on('init') @@ -20,12 +18,8 @@ export default Ember.Controller.extend(ModalFunctionality, Ember.Evented, { return categories.map(c => bufProxy.create({ content: c })); }, - categoriesOrdered: function() { - return SortableArrayProxy.create({ - sortProperties: ['content.position'], - content: this.get('categoriesBuffered') - }); - }.property('categoriesBuffered'), + categoriesSorting: ['position'], + categoriesOrdered: Ember.computed.sort('categoriesBuffered', 'categoriesSorting'), showFixIndices: function() { const cats = this.get('categoriesOrdered'); From 985daf5c722c2a620f9061aada1bdb75f65a973f Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 1 Dec 2016 12:01:23 -0500 Subject: [PATCH 045/122] FIX: summary should not include certain post types --- app/mailers/user_notifications.rb | 2 ++ spec/mailers/user_notifications_spec.rb | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 76bf33beac..e15544fd64 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -137,6 +137,8 @@ class UserNotifications < ActionMailer::Base @popular_posts = if SiteSetting.digest_posts > 0 Post.for_mailing_list(user, min_date) + .where('posts.post_type = ?', Post.types[:regular]) + .where('posts.deleted_at IS NULL AND posts.hidden = false AND posts.user_deleted = false') .where("posts.post_number > ? AND posts.score > ?", 1, 5.0) .order("posts.score DESC") .limit(SiteSetting.digest_posts) diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 51f73a58ae..a427b6d3b0 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -155,8 +155,7 @@ describe UserNotifications do context "with new topics" do before do - Topic.stubs(:for_digest).returns([Fabricate(:topic, user: Fabricate(:coding_horror))]) - Topic.stubs(:new_since_last_seen).returns(Topic.none) + Fabricate(:topic, user: Fabricate(:coding_horror)) end it "works" do @@ -184,6 +183,28 @@ describe UserNotifications do expect(html).to_not include deleted.title expect(html).to_not include post.raw end + + it "excludes whispers and other post types that don't belong" do + t = Fabricate(:topic, user: Fabricate(:user), title: "Who likes the same stuff I like?") + whisper = Fabricate(:post, topic: t, score: 100.0, post_number: 2, raw: "You like weird stuff", post_type: Post.types[:whisper]) + mod_action = Fabricate(:post, topic: t, score: 100.0, post_number: 3, raw: "This topic unlisted", post_type: Post.types[:moderator_action]) + small_action = Fabricate(:post, topic: t, score: 100.0, post_number: 4, raw: "A small action", post_type: Post.types[:small_action]) + html = subject.html_part.body.to_s + expect(html).to_not include whisper.raw + expect(html).to_not include mod_action.raw + expect(html).to_not include small_action.raw + end + + it "excludes deleted and hidden posts" do + t = Fabricate(:topic, user: Fabricate(:user), title: "Post objectionable stuff here") + deleted = Fabricate(:post, topic: t, score: 100.0, post_number: 2, raw: "This post is uncalled for", deleted_at: 5.minutes.ago) + hidden = Fabricate(:post, topic: t, score: 100.0, post_number: 3, raw: "Try to find this post", hidden: true, hidden_at: 5.minutes.ago, hidden_reason_id: Post.hidden_reasons[:flagged_by_tl3_user]) + user_deleted = Fabricate(:post, topic: t, score: 100.0, post_number: 4, raw: "I regret this post", user_deleted: true) + html = subject.html_part.body.to_s + expect(html).to_not include deleted.raw + expect(html).to_not include hidden.raw + expect(html).to_not include user_deleted.raw + end end end From 8a0cf1b90e7fc335efb4763047a2086954c54880 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 1 Dec 2016 12:17:23 -0500 Subject: [PATCH 046/122] FIX: if username and name are the same, don't show both in summary emails --- app/views/user_notifications/digest.html.erb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 04afc7056b..061f90d16e 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -129,9 +129,11 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
<%- if show_image_with_url(t.image_url) -%> @@ -261,9 +263,11 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
+ <%= email_excerpt(t.first_post.cooked) %>
-
<%= t.user.try(:username) -%>
- <% if SiteSetting.enable_names? && t.user.try(:name).present? %> -

<%= t.user.name -%>

+ <% if t.user %> +
<%= t.user.username -%>
+ <% if SiteSetting.enable_names? && t.user.name.present? && t.user.name.downcase != t.user.username.downcase %> +

<%= t.user.name -%>

+ <% end %> <% end %>
-
<%= post.user.try(:username) -%>
- <% if SiteSetting.enable_names? && post.user.try(:name) %> -

<%= post.user.name -%>

+ <% if post.user %> +
<%= post.user.username -%>
+ <% if SiteSetting.enable_names? && post.user.name && post.user.name.downcase != post.user.username %> +

<%= post.user.name -%>

+ <% end %> <% end %>
From 62763f025c23264ba41ae64d1fce11e3f1dd4fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 1 Dec 2016 18:34:47 +0100 Subject: [PATCH 047/122] FIX: wasn't able to parse FROM email in the embedded email --- lib/email/receiver.rb | 19 ++++++++++++---- spec/components/email/receiver_spec.rb | 26 ++++++++++++++++++++++ spec/fixtures/emails/forwarded_email_1.eml | 18 +++++++++++++++ spec/fixtures/emails/forwarded_email_2.eml | 18 +++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 spec/fixtures/emails/forwarded_email_1.eml create mode 100644 spec/fixtures/emails/forwarded_email_2.eml diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index faaf55fe46..45a722d6c2 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -244,14 +244,21 @@ module Email address_field.decoded from_address = address_field.address from_display_name = address_field.display_name.try(:to_s) - return [from_address.downcase, from_display_name] if from_address["@"] + return [from_address&.downcase, from_display_name&.strip] if from_address["@"] end end - from_address = mail.from[/<([^>]+)>/, 1] - from_display_name = mail.from[/^([^<]+)/, 1] + if mail.from[/<[^>]+>/] + from_address = mail.from[/<([^>]+)>/, 1] + from_display_name = mail.from[/^([^<]+)/, 1] + end - [from_address.downcase, from_display_name] + if (from_address.blank? || !from_address["@"]) && mail.from[/\[mailto:[^\]]+\]/] + from_address = mail.from[/\[mailto:([^\]]+)\]/, 1] + from_display_name = mail.from[/^([^\[]+)/, 1] + end + + [from_address&.downcase, from_display_name&.strip] end def subject @@ -376,6 +383,9 @@ module Email def process_forwarded_email(destination, user) embedded = Mail.new(@embedded_email_raw) email, display_name = parse_from_field(embedded) + + return false if email.blank? || !email["@"] + embedded_user = find_or_create_user(email, display_name) raw = try_to_encode(embedded.decoded, "UTF-8").presence || embedded.to_s title = embedded.subject.presence || subject @@ -387,6 +397,7 @@ module Email raw: raw, title: title, archetype: Archetype.private_message, + target_usernames: [user.username], target_group_names: [group.name], is_group_message: true, skip_validations: true, diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 29ba74adfc..935fe3be06 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -383,6 +383,32 @@ describe Email::Receiver do expect(Post.last.raw).to match(/discourse\.rb/) end + it "handles forwarded emails" do + SiteSetting.enable_forwarded_emails = true + expect { process(:forwarded_email_1) }.to change(Topic, :count) + + forwarded_post, last_post = *Post.last(2) + + expect(forwarded_post.user.email).to eq("some@one.com") + expect(last_post.user.email).to eq("ba@bar.com") + + expect(forwarded_post.raw).to match(/XoXo/) + expect(last_post.raw).to match(/can you have a look at this email below/) + end + + it "handles weirdly forwarded emails" do + SiteSetting.enable_forwarded_emails = true + expect { process(:forwarded_email_2) }.to change(Topic, :count) + + forwarded_post, last_post = *Post.last(2) + + expect(forwarded_post.user.email).to eq("some@one.com") + expect(last_post.user.email).to eq("ba@bar.com") + + expect(forwarded_post.raw).to match(/XoXo/) + expect(last_post.raw).to match(/can you have a look at this email below/) + end + end context "new topic in a category" do diff --git a/spec/fixtures/emails/forwarded_email_1.eml b/spec/fixtures/emails/forwarded_email_1.eml new file mode 100644 index 0000000000..30fb904190 --- /dev/null +++ b/spec/fixtures/emails/forwarded_email_1.eml @@ -0,0 +1,18 @@ +Message-ID: <58@foo.bar.mail> +From: Ba Bar +To: Team +Date: Mon, 1 Dec 2016 13:37:42 +0100 +Subject: FW: Discoursing much? + +@team, can you have a look at this email below? + +From: Some One +To: Ba Bar +Date: Mon, 1 Dec 2016 00:13:37 +0100 +Subject: Discoursing much? + +Hello Ba Bar, + +Discoursing much today? + +XoXo diff --git a/spec/fixtures/emails/forwarded_email_2.eml b/spec/fixtures/emails/forwarded_email_2.eml new file mode 100644 index 0000000000..d31d5c44ea --- /dev/null +++ b/spec/fixtures/emails/forwarded_email_2.eml @@ -0,0 +1,18 @@ +Message-ID: <59@foo.bar.mail> +From: Ba Bar +To: Team +Date: Mon, 1 Dec 2016 13:37:42 +0100 +Subject: Re: Discoursing much? + +@team, can you have a look at this email below? + +From: Some One [mailto:some@one.com] +To: Ba Bar +Date: Mon, 1 Dec 2016 00:13:37 +0100 +Subject: Discoursing much? + +Hello Ba Bar, + +Discoursing much today? + +XoXo From eb453d0f820060d79fb1fdac34f39cf28c2a1bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 1 Dec 2016 18:43:56 +0100 Subject: [PATCH 048/122] the note in a FWed email should be a whisper only in PM and when the author is member of the group --- lib/email/receiver.rb | 5 ++++- spec/components/email/receiver_spec.rb | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 45a722d6c2..b64c250957 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -420,11 +420,14 @@ module Email end if post && post.topic && @before_embedded.present? + post_type = Post.types[:regular] + post_type = Post.types[:whisper] if post.topic.private_message? && group.usernames[user.username] + create_reply(user: user, raw: @before_embedded, post: post, topic: post.topic, - post_type: Post.types[:whisper]) + post_type: post_type) end true diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 935fe3be06..0a7b0a1bed 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -394,9 +394,14 @@ describe Email::Receiver do expect(forwarded_post.raw).to match(/XoXo/) expect(last_post.raw).to match(/can you have a look at this email below/) + + expect(last_post.post_type).to eq(Post.types[:regular]) end it "handles weirdly forwarded emails" do + group.add(Fabricate(:user, email: "ba@bar.com")) + group.save + SiteSetting.enable_forwarded_emails = true expect { process(:forwarded_email_2) }.to change(Topic, :count) @@ -407,6 +412,8 @@ describe Email::Receiver do expect(forwarded_post.raw).to match(/XoXo/) expect(last_post.raw).to match(/can you have a look at this email below/) + + expect(last_post.post_type).to eq(Post.types[:whisper]) end end From 4820ebd76cb504ba2b9d016161c455fd0accbeee Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 1 Dec 2016 14:08:00 -0500 Subject: [PATCH 049/122] FIX: Don't modify properties in `didInsertElement` --- .../javascripts/discourse/components/topic-progress.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index 768ad10356..85cb520955 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -65,7 +65,7 @@ export default Ember.Component.extend({ const prevEvent = this.get('prevEvent'); if (prevEvent) { - this._topicScrolled(prevEvent); + Ember.run.scheduleOnce('afterRender', this, this._topicScrolled, prevEvent); } else { Ember.run.scheduleOnce('afterRender', this, this._updateProgressBar); } From 576a4241308a60bf012e7058dd661a3751a21311 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 1 Dec 2016 14:20:01 -0500 Subject: [PATCH 050/122] FEATURE: number of new topics at the end of summary email can be controlled by a new setting, digest_other_topics --- app/mailers/user_notifications.rb | 2 +- config/locales/server.en.yml | 3 ++- config/site_settings.yml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index e15544fd64..b4f2b76559 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -130,7 +130,7 @@ class UserNotifications < ActionMailer::Base end # Now fetch some topics and posts to show - topics_for_digest = Topic.for_digest(user, min_date, limit: SiteSetting.digest_topics + 3, top_order: true).to_a + topics_for_digest = Topic.for_digest(user, min_date, limit: SiteSetting.digest_topics + SiteSetting.digest_other_topics, top_order: true).to_a @popular_topics = topics_for_digest[0,SiteSetting.digest_topics] @other_new_for_you = topics_for_digest.size > SiteSetting.digest_topics ? topics_for_digest[SiteSetting.digest_topics..-1] : [] diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index bf47274199..8c10bd7f82 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1286,8 +1286,9 @@ en: allow_animated_thumbnails: "Generates animated thumbnails of animated gifs." default_avatars: "URLs to avatars that will be used by default for new users until they change them." automatically_download_gravatars: "Download Gravatars for users upon account creation or email change." - digest_topics: "The maximum number of topics to display in the email summary." + digest_topics: "The maximum number of popular topics to display in the email summary." digest_posts: "The maximum number of popular posts to display in the email summary." + digest_other_topics: "The maximum number of topics to show in the 'New in topics and categories you follow' section of the email summary." digest_min_excerpt_length: "Minimum post excerpt in the email summary, in characters." delete_digest_email_after_days: "Suppress summary emails for users not seen on the site for more than (n) days." digest_suppress_categories: "Suppress these categories from summary emails." diff --git a/config/site_settings.yml b/config/site_settings.yml index b6f233ebc7..40ff14fda4 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -588,6 +588,7 @@ email: default: 5 min: 1 digest_posts: 3 + digest_other_topics: 5 delete_digest_email_after_days: 365 digest_suppress_categories: type: category_list From 4f44713e8e05e7b6954b2c68524f686c13d1b693 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 1 Dec 2016 15:37:24 -0500 Subject: [PATCH 051/122] Allow untranslated plugins to set modal body titles --- app/assets/javascripts/discourse-common/resolver.js.es6 | 2 +- app/assets/javascripts/discourse/components/d-modal-body.js.es6 | 2 +- app/assets/javascripts/discourse/components/d-modal.js.es6 | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse-common/resolver.js.es6 b/app/assets/javascripts/discourse-common/resolver.js.es6 index a0089ae7d3..a69efc4e30 100644 --- a/app/assets/javascripts/discourse-common/resolver.js.es6 +++ b/app/assets/javascripts/discourse-common/resolver.js.es6 @@ -135,7 +135,7 @@ export function buildResolver(baseName) { }, findPluginTemplate(parsedName) { - var pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/")); + const pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/")); return this.findTemplate(pluginParsedName); }, diff --git a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 index 638db7d885..42c797ddf9 100644 --- a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 @@ -28,7 +28,7 @@ export default Ember.Component.extend({ } } - this.appEvents.trigger('modal:body-shown', this.getProperties('title')); + this.appEvents.trigger('modal:body-shown', this.getProperties('title', 'rawTitle')); }, _flash(msg) { diff --git a/app/assets/javascripts/discourse/components/d-modal.js.es6 b/app/assets/javascripts/discourse/components/d-modal.js.es6 index a8de2b82ea..475c79d049 100644 --- a/app/assets/javascripts/discourse/components/d-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal.js.es6 @@ -19,6 +19,8 @@ export default Ember.Component.extend({ this.appEvents.on('modal:body-shown', data => { if (data.title) { this.set('title', I18n.t(data.title)); + } else if (data.rawTitle) { + this.set('title', data.rawTitle); } }); }, From 3812c07958f72a19f3f7248aa5f281893d4f0a6d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 2 Dec 2016 11:45:57 +0800 Subject: [PATCH 052/122] Add query params for site settings filter. --- .../javascripts/admin/controllers/admin-site-settings.js.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 02a907d7af..9cd65adf10 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -1,6 +1,7 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ + queryParams: ["filter"], filter: null, onlyOverridden: false, filtered: Ember.computed.notEmpty('filter'), From c04d4171ff9c8ad95f494af51ca7a35928c86011 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 17:03:31 +1100 Subject: [PATCH 053/122] FIX: whisper no longer experimental - Regular users are not notified of whispers - Regular users no longer have "stuck" topics in unread - Additional tracking for staff highest post number - Remove a bunch of unused columns in topics table --- app/controllers/application_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/models/post.rb | 2 +- app/models/post_action.rb | 6 +- app/models/topic.rb | 101 +++++++++++++++--- app/models/topic_tracking_state.rb | 30 ++++-- app/models/topic_user.rb | 16 ++- app/serializers/listable_topic_serializer.rb | 2 +- config/locales/server.en.yml | 2 +- ...202011139_add_whisper_support_to_topics.rb | 16 +++ lib/post_creator.rb | 7 +- lib/post_jobs_enqueuer.rb | 2 +- lib/topic_query.rb | 13 ++- lib/unread.rb | 10 +- spec/components/post_creator_spec.rb | 33 +++++- spec/components/topic_query_spec.rb | 32 ++++-- spec/components/unread_spec.rb | 82 +++++++++----- spec/fabricators/topic_user_fabricator.rb | 4 + spec/models/post_action_spec.rb | 24 ++--- spec/models/topic_spec.rb | 17 +++ spec/models/topic_tracking_state_spec.rb | 24 ++--- 21 files changed, 324 insertions(+), 103 deletions(-) create mode 100644 db/migrate/20161202011139_add_whisper_support_to_topics.rb create mode 100644 spec/fabricators/topic_user_fabricator.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a6a1300b51..795a46dfa4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -382,7 +382,7 @@ class ApplicationController < ActionController::Base def preload_current_user_data store_preloaded("currentUser", MultiJson.dump(CurrentUserSerializer.new(current_user, scope: guardian, root: false))) - report = TopicTrackingState.report(current_user.id) + report = TopicTrackingState.report(current_user) serializer = ActiveModel::ArraySerializer.new(report, each_serializer: TopicTrackingStateSerializer) store_preloaded("topicTrackingStates", MultiJson.dump(serializer)) end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0c226d93b2..d67c12455e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -152,7 +152,7 @@ class UsersController < ApplicationController user = fetch_user_from_params guardian.ensure_can_edit!(user) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) serializer = ActiveModel::ArraySerializer.new(report, each_serializer: TopicTrackingStateSerializer) render json: MultiJson.dump(serializer) diff --git a/app/models/post.rb b/app/models/post.rb index 84566d5a8a..e262f259ba 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -24,7 +24,7 @@ class Post < ActiveRecord::Base rate_limit :limit_posts_per_day belongs_to :user - belongs_to :topic, counter_cache: :posts_count + belongs_to :topic belongs_to :reply_to_user, class_name: "User" diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 5cee56bdde..5bc031ccae 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -435,8 +435,10 @@ SQL post_action_type: post_action_type_key) end - topic_count = Post.where(topic_id: topic_id).sum(column) - Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count] + if column == "like_count" + topic_count = Post.where(topic_id: topic_id).sum(column) + Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count] + end if PostActionType.notify_flag_type_ids.include?(post_action_type_id) PostAction.update_flagged_posts_count diff --git a/app/models/topic.rb b/app/models/topic.rb index a8786ac781..567f0d4e6b 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -466,23 +466,103 @@ class Topic < ActiveRecord::Base end # Atomically creates the next post number - def self.next_post_number(topic_id, reply = false) + def self.next_post_number(topic_id, reply = false, whisper = false) highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i - reply_sql = reply ? ", reply_count = reply_count + 1" : "" - result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql} - WHERE id = ? RETURNING highest_post_number", highest, topic_id) - result.first['highest_post_number'].to_i + if whisper + + result = exec_sql("UPDATE topics + SET highest_staff_post_number = ? + 1 + WHERE id = ? + RETURNING highest_staff_post_number", highest, topic_id) + + result.first['highest_staff_post_number'].to_i + + else + + reply_sql = reply ? ", reply_count = reply_count + 1" : "" + + result = exec_sql("UPDATE topics + SET highest_staff_post_number = :highest + 1, + highest_post_number = :highest + 1#{reply_sql}, + posts_count = posts_count + 1 + WHERE id = :topic_id + RETURNING highest_post_number", highest: highest, topic_id: topic_id) + + result.first['highest_post_number'].to_i + end end + + def self.reset_all_highest! + exec_sql < 4 + GROUP BY topic_id +) +UPDATE topics +SET + highest_staff_post_number = X.highest_post_number, + highest_post_number = Y.highest_post_number, + last_posted_at = Y.last_posted_at, + posts_count = Y.posts_count +FROM X, Y +WHERE + X.topic_id = topics.id AND + Y.topic_id = topics.id AND ( + topics.highest_staff_post_number <> X.highest_post_number OR + topics.highest_post_number <> Y.highest_post_number OR + topics.last_posted_at <> Y.last_posted_at OR + topics.posts_count <> Y.posts_count + ) +SQL + end + + # If a post is deleted we have to update our highest post counters def self.reset_highest(topic_id) result = exec_sql "UPDATE topics - SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL), - posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id), - last_posted_at = (SELECT MAX(created_at) FROM POSTS WHERE topic_id = :topic_id AND deleted_at IS NULL) + SET + highest_staff_post_number = ( + SELECT COALESCE(MAX(post_number), 0) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL + ), + highest_post_number = ( + SELECT COALESCE(MAX(post_number), 0) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL AND + post_type <> 4 + ), + posts_count = ( + SELECT count(*) FROM posts + WHERE deleted_at IS NULL AND + topic_id = :topic_id AND + post_type <> 4 + ), + + last_posted_at = ( + SELECT MAX(created_at) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL AND + post_type <> 4 + ) WHERE id = :topic_id RETURNING highest_post_number", topic_id: topic_id + highest_post_number = result.first['highest_post_number'].to_i # Update the forum topic user records @@ -724,10 +804,7 @@ class Topic < ActiveRecord::Base end def update_action_counts - PostActionType.types.each_key do |type| - count_field = "#{type}_count" - update_column(count_field, Post.where(topic_id: id).sum(count_field)) - end + update_column(:like_count, Post.where(topic_id: id).sum(:like_count)) end def posters_summary(options = {}) diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index 97bd347aee..b8a30b830d 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -38,7 +38,7 @@ class TopicTrackingState publish_read(topic.id, 1, topic.user_id) end - def self.publish_latest(topic) + def self.publish_latest(topic, staff_only=false) return unless topic.archetype == "regular" message = { @@ -52,15 +52,25 @@ class TopicTrackingState } } - group_ids = topic.category && topic.category.secure_group_ids + group_ids = + if staff_only + [Group::AUTO_GROUPS[:staff]] + else + topic.category && topic.category.secure_group_ids + end MessageBus.publish("/latest", message.as_json, group_ids: group_ids) end def self.publish_unread(post) # TODO at high scale we are going to have to defer this, # perhaps cut down to users that are around in the last 7 days as well - # - group_ids = post.topic.category && post.topic.category.secure_group_ids + + group_ids = + if post.post_type == Post.types[:whisper] + [Group::AUTO_GROUPS[:staff]] + else + post.topic.category && post.topic.category.secure_group_ids + end TopicUser .tracking(post.topic_id) @@ -148,7 +158,7 @@ class TopicTrackingState ).where_values[0] end - def self.report(user_id, topic_id = nil) + def self.report(user, topic_id = nil) # Sam: this is a hairy report, in particular I need custom joins and fancy conditions # Dropping to sql_builder so I can make sense of it. @@ -160,12 +170,12 @@ class TopicTrackingState # cycles from usual requests # # - sql = report_raw_sql(topic_id: topic_id, skip_unread: true, skip_order: true) + sql = report_raw_sql(topic_id: topic_id, skip_unread: true, skip_order: true, staff: user.staff?) sql << "\nUNION ALL\n\n" - sql << report_raw_sql(topic_id: topic_id, skip_new: true, skip_order: true) + sql << report_raw_sql(topic_id: topic_id, skip_new: true, skip_order: true, staff: user.staff?) SqlBuilder.new(sql) - .map_exec(TopicTrackingState, user_id: user_id, topic_id: topic_id) + .map_exec(TopicTrackingState, user_id: user.id, topic_id: topic_id) end @@ -176,7 +186,7 @@ class TopicTrackingState if opts && opts[:skip_unread] "1=0" else - TopicQuery.unread_filter(Topic).where_values.join(" AND ") + TopicQuery.unread_filter(Topic, staff: opts && opts[:staff]).where_values.join(" AND ") end new = @@ -190,7 +200,7 @@ class TopicTrackingState u.id AS user_id, topics.id AS topic_id, topics.created_at, - highest_post_number, + #{opts && opts[:staff] ? "highest_staff_post_number highest_post_number" : "highest_post_number"}, last_read_post_number, c.id AS category_id, tu.notification_level" diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index 9738083ba7..f4369f5698 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -236,6 +236,8 @@ SQL topic_users.notification_level, tu.notification_level old_level, tu.last_read_post_number " + UPDATE_TOPIC_USER_SQL_STAFF = UPDATE_TOPIC_USER_SQL.gsub("highest_post_number", "highest_staff_post_number") + INSERT_TOPIC_USER_SQL = "INSERT INTO topic_users (user_id, topic_id, last_read_post_number, highest_seen_post_number, last_visited_at, first_visited_at, notification_level) SELECT :user_id, :topic_id, :post_number, ft.highest_post_number, :now, :now, :new_status FROM topics AS ft @@ -245,6 +247,8 @@ SQL FROM topic_users AS ftu WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)" + INSERT_TOPIC_USER_SQL_STAFF = INSERT_TOPIC_USER_SQL.gsub("highest_post_number", "highest_staff_post_number") + def update_last_read(user, topic_id, post_number, msecs, opts={}) return if post_number.blank? msecs = 0 if msecs.to_i < 0 @@ -265,7 +269,11 @@ SQL # ... user visited the topic but did not read the posts # # 86400000 = 1 day - rows = exec_sql(UPDATE_TOPIC_USER_SQL,args).values + rows = if user.staff? + exec_sql(UPDATE_TOPIC_USER_SQL_STAFF,args).values + else + exec_sql(UPDATE_TOPIC_USER_SQL,args).values + end if rows.length == 1 before = rows[0][1].to_i @@ -295,7 +303,11 @@ SQL user.update_posts_read!(post_number, mobile: opts[:mobile]) begin - exec_sql(INSERT_TOPIC_USER_SQL, args) + if user.staff? + exec_sql(INSERT_TOPIC_USER_SQL_STAFF, args) + else + exec_sql(INSERT_TOPIC_USER_SQL, args) + end rescue PG::UniqueViolation # if record is inserted between two statements this can happen # we retry once to avoid failing the req diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index 3589d7a231..e8cd1a7f15 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -109,7 +109,7 @@ class ListableTopicSerializer < BasicTopicSerializer protected def unread_helper - @unread_helper ||= Unread.new(object, object.user_data) + @unread_helper ||= Unread.new(object, object.user_data, scope) end end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index bf47274199..896e76aa2a 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -967,7 +967,7 @@ en: email_token_grace_period_hours: "Forgot password / activate account tokens are still valid for a grace period of (n) hours after being redeemed." enable_badges: "Enable the badge system" - enable_whispers: "Allow staff private communication within topic. (experimental)" + enable_whispers: "Allow staff private communication within topics." allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines." email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" diff --git a/db/migrate/20161202011139_add_whisper_support_to_topics.rb b/db/migrate/20161202011139_add_whisper_support_to_topics.rb new file mode 100644 index 0000000000..0e065889e8 --- /dev/null +++ b/db/migrate/20161202011139_add_whisper_support_to_topics.rb @@ -0,0 +1,16 @@ +class AddWhisperSupportToTopics < ActiveRecord::Migration + def up + remove_column :topics, :bookmark_count + remove_column :topics, :off_topic_count + remove_column :topics, :illegal_count + remove_column :topics, :inappropriate_count + remove_column :topics, :notify_user_count + + add_column :topics, :highest_staff_post_number, :int, default: 0, null: false + execute "UPDATE topics SET highest_staff_post_number = highest_post_number" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 0cd26aa33d..9b4fb345dd 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -146,6 +146,9 @@ class PostCreator end if @post && errors.blank? + # update counters etc. + @post.topic.reload + publish track_latest_on_category @@ -199,7 +202,9 @@ class PostCreator set_reply_info(post) post.word_count = post.raw.scan(/[[:word:]]+/).size - post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?) + + whisper = post.post_type == Post.types[:whisper] + post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?, whisper) cooking_options = post.cooking_options || {} cooking_options[:topic_id] = post.topic_id diff --git a/lib/post_jobs_enqueuer.rb b/lib/post_jobs_enqueuer.rb index c5f1f6a9d1..0e05c565f9 100644 --- a/lib/post_jobs_enqueuer.rb +++ b/lib/post_jobs_enqueuer.rb @@ -35,7 +35,7 @@ class PostJobsEnqueuer def after_post_create TopicTrackingState.publish_unread(@post) if @post.post_number > 1 - TopicTrackingState.publish_latest(@topic) + TopicTrackingState.publish_latest(@topic, @post.post_type == Post.types[:whisper]) Jobs.enqueue_in( SiteSetting.email_time_window_mins.minutes, diff --git a/lib/topic_query.rb b/lib/topic_query.rb index f13cbbd8bc..3da879c6a3 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -242,9 +242,12 @@ class TopicQuery .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking]) end - def self.unread_filter(list) - list.where("tu.last_read_post_number < topics.highest_post_number") - .where("COALESCE(tu.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) + def self.unread_filter(list, opts) + col_name = opts[:staff] ? "highest_staff_post_number" : "highest_post_number" + + list.where("tu.last_read_post_number < topics.#{col_name}") + .where("COALESCE(tu.notification_level, :regular) >= :tracking", + regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) end def prioritize_pinned_topics(topics, options) @@ -320,7 +323,7 @@ class TopicQuery end def unread_results(options={}) - result = TopicQuery.unread_filter(default_results(options.reverse_merge(:unordered => true))) + result = TopicQuery.unread_filter(default_results(options.reverse_merge(:unordered => true)), staff: @user.try(:staff?)) .order('CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END') self.class.results_filter_callbacks.each do |filter_callback| @@ -656,7 +659,7 @@ class TopicQuery end def unread_messages(params) - TopicQuery.unread_filter(messages_for_groups_or_user(params[:my_group_ids])) + TopicQuery.unread_filter(messages_for_groups_or_user(params[:my_group_ids]), staff: @user.try(:staff?)) .limit(params[:count]) end diff --git a/lib/unread.rb b/lib/unread.rb index a5a062d319..f04ffcd228 100644 --- a/lib/unread.rb +++ b/lib/unread.rb @@ -2,7 +2,8 @@ class Unread # This module helps us calculate unread and new post counts - def initialize(topic, topic_user) + def initialize(topic, topic_user, guardian) + @guardian = guardian @topic = topic @topic_user = topic_user end @@ -18,9 +19,12 @@ class Unread def new_posts return 0 if @topic_user.highest_seen_post_number.blank? return 0 if do_not_notify?(@topic_user.notification_level) - return 0 if (@topic_user.last_read_post_number||0) > @topic.highest_post_number - new_posts = (@topic.highest_post_number - @topic_user.highest_seen_post_number) + highest_post_number = @guardian.is_staff? ? @topic.highest_staff_post_number : @topic.highest_post_number + + return 0 if (@topic_user.last_read_post_number||0) > highest_post_number + + new_posts = (highest_post_number - @topic_user.highest_seen_post_number) new_posts = 0 if new_posts < 0 return new_posts end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index ab1bd54866..a428bb03b8 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -334,7 +334,12 @@ describe PostCreator do context 'whisper' do let!(:topic) { Fabricate(:topic, user: user) } - it 'forces replies to whispers to be whispers' do + it 'whispers do not mess up the public view' do + + first = PostCreator.new(user, + topic_id: topic.id, + raw: 'this is the first post').create + whisper = PostCreator.new(user, topic_id: topic.id, reply_to_post_number: 1, @@ -344,6 +349,7 @@ describe PostCreator do expect(whisper).to be_present expect(whisper.post_type).to eq(Post.types[:whisper]) + whisper_reply = PostCreator.new(user, topic_id: topic.id, reply_to_post_number: whisper.post_number, @@ -352,6 +358,29 @@ describe PostCreator do expect(whisper_reply).to be_present expect(whisper_reply.post_type).to eq(Post.types[:whisper]) + + + first.reload + # does not leak into the OP + expect(first.reply_count).to eq(0) + + topic.reload + + # cause whispers should not muck up that number + expect(topic.highest_post_number).to eq(1) + expect(topic.reply_count).to eq(0) + expect(topic.posts_count).to eq(1) + expect(topic.highest_staff_post_number).to eq(3) + + topic.update_columns(highest_staff_post_number:0, highest_post_number:0, posts_count: 0, last_posted_at: 1.year.ago) + + Topic.reset_highest(topic.id) + + topic.reload + expect(topic.highest_post_number).to eq(1) + expect(topic.posts_count).to eq(1) + expect(topic.last_posted_at).to eq(first.created_at) + expect(topic.highest_staff_post_number).to eq(3) end end @@ -624,6 +653,8 @@ describe PostCreator do _post2 = create_post(user: post1.user, topic_id: post1.topic_id) post1.topic.reload + + expect(post1.topic.posts_count).to eq(3) expect(post1.topic.closed).to eq(true) end end diff --git a/spec/components/topic_query_spec.rb b/spec/components/topic_query_spec.rb index c50319ff27..619a080aae 100644 --- a/spec/components/topic_query_spec.rb +++ b/spec/components/topic_query_spec.rb @@ -409,6 +409,29 @@ describe TopicQuery do end end + context 'with whispers' do + + it 'correctly shows up in unread for staff' do + + first = create_post(raw: 'this is the first post', title: 'super amazing title') + + _whisper = create_post(topic_id: first.topic.id, + post_type: Post.types[:whisper], + raw: 'this is a whispered reply') + + topic_id = first.topic.id + + TopicUser.update_last_read(user, topic_id, first.post_number, 1) + TopicUser.update_last_read(admin, topic_id, first.post_number, 1) + + TopicUser.change(user.id, topic_id, notification_level: TopicUser.notification_levels[:tracking]) + TopicUser.change(admin.id, topic_id, notification_level: TopicUser.notification_levels[:tracking]) + + expect(TopicQuery.new(user).list_unread.topics).to eq([]) + expect(TopicQuery.new(admin).list_unread.topics).to eq([first.topic]) + end + end + context 'with read data' do let!(:partially_read) { Fabricate(:post, user: creator).topic } let!(:fully_read) { Fabricate(:post, user: creator).topic } @@ -419,8 +442,9 @@ describe TopicQuery do end context 'list_unread' do - it 'contains no topics' do + it 'lists topics correctly' do expect(topic_query.list_unread.topics).to eq([]) + expect(topic_query.list_read.topics).to match_array([fully_read, partially_read]) end end @@ -435,11 +459,6 @@ describe TopicQuery do end end - context 'list_read' do - it 'contain both topics ' do - expect(topic_query.list_read.topics).to match_array([fully_read, partially_read]) - end - end end end @@ -630,7 +649,6 @@ describe TopicQuery do related_by_group_pm = create_pm(sender, target_group_names: [group_with_user.name]) read(user, related_by_group_pm, 1) - expect(TopicQuery.new(user).list_suggested_for(pm_to_group).topics.map(&:id)).to( eq([related_by_group_pm.id, related_by_user_pm.id, pm_to_user.id]) ) diff --git a/spec/components/unread_spec.rb b/spec/components/unread_spec.rb index cfbf457053..21e4b28b59 100644 --- a/spec/components/unread_spec.rb +++ b/spec/components/unread_spec.rb @@ -3,62 +3,86 @@ require 'unread' describe Unread do - - before do - @topic = Fabricate(:topic, posts_count: 13, highest_post_number: 13) - @topic.notifier.watch_topic!(@topic.user_id) - @topic_user = TopicUser.get(@topic, @topic.user) - @topic_user.stubs(:notification_level).returns(TopicUser.notification_levels[:tracking]) - @topic_user.notification_level = TopicUser.notification_levels[:tracking] - @unread = Unread.new(@topic, @topic_user) + let (:user) { Fabricate.build(:user, id: 1) } + let (:topic) do + Fabricate.build(:topic, + posts_count: 13, + highest_staff_post_number: 15, + highest_post_number: 13, + id: 1) end + let (:topic_user) do + Fabricate.build(:topic_user, + notification_level: TopicUser.notification_levels[:tracking], + topic_id: topic.id, + user_id: user.id) + end + + def unread + Unread.new(topic, topic_user, Guardian.new(user)) + end + + describe 'staff counts' do + it 'shoule correctly return based on staff post number' do + + user.admin = true + + topic_user.last_read_post_number = 13 + topic_user.highest_seen_post_number = 13 + + expect(unread.unread_posts).to eq(0) + expect(unread.new_posts).to eq(2) + end + end + + describe 'unread_posts' do it 'should have 0 unread posts if the user has seen all posts' do - @topic_user.stubs(:last_read_post_number).returns(13) - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.unread_posts).to eq(0) + topic_user.last_read_post_number = 13 + topic_user.highest_seen_post_number = 13 + expect(unread.unread_posts).to eq(0) end it 'should have 6 unread posts if the user has seen all but 6 posts' do - @topic_user.stubs(:last_read_post_number).returns(5) - @topic_user.stubs(:highest_seen_post_number).returns(11) - expect(@unread.unread_posts).to eq(6) + topic_user.last_read_post_number = 5 + topic_user.highest_seen_post_number = 11 + expect(unread.unread_posts).to eq(6) end it 'should have 0 unread posts if the user has seen more posts than exist (deleted)' do - @topic_user.stubs(:last_read_post_number).returns(100) - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.unread_posts).to eq(0) + topic_user.last_read_post_number = 100 + topic_user.highest_seen_post_number = 13 + expect(unread.unread_posts).to eq(0) end end describe 'new_posts' do it 'should have 0 new posts if the user has read all posts' do - @topic_user.stubs(:last_read_post_number).returns(13) - expect(@unread.new_posts).to eq(0) + topic_user.last_read_post_number = 13 + expect(unread.new_posts).to eq(0) end it 'returns 0 when the topic is the same length as when you last saw it' do - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 13 + expect(unread.new_posts).to eq(0) end it 'has 3 new posts if the user has read 10 posts' do - @topic_user.stubs(:highest_seen_post_number).returns(10) - expect(@unread.new_posts).to eq(3) + topic_user.highest_seen_post_number = 10 + expect(unread.new_posts).to eq(3) end it 'has 0 new posts if the user has read 10 posts but is not tracking' do - @topic_user.stubs(:highest_seen_post_number).returns(10) - @topic_user.stubs(:notification_level).returns(TopicUser.notification_levels[:regular]) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 10 + topic_user.notification_level = TopicUser.notification_levels[:regular] + expect(unread.new_posts).to eq(0) end it 'has 0 new posts if the user read more posts than exist (deleted)' do - @topic_user.stubs(:highest_seen_post_number).returns(16) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 16 + expect(unread.new_posts).to eq(0) end - end + end diff --git a/spec/fabricators/topic_user_fabricator.rb b/spec/fabricators/topic_user_fabricator.rb new file mode 100644 index 0000000000..b299806f70 --- /dev/null +++ b/spec/fabricators/topic_user_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:topic_user) do + user + topic +end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index 9321dedd81..fb621269df 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -199,10 +199,6 @@ describe PostAction do expect { bookmark.save; post.reload }.to change(post, :bookmark_count).by(1) end - it "increases the forum topic's bookmark count when saved" do - expect { bookmark.save; post.topic.reload }.to change(post.topic, :bookmark_count).by(1) - end - describe 'when deleted' do before do @@ -218,9 +214,6 @@ describe PostAction do expect { post.reload }.to change(post, :bookmark_count).by(-1) end - it 'reduces the bookmark count of the forum topic' do - expect { @topic.reload }.to change(post.topic, :bookmark_count).by(-1) - end end end @@ -291,19 +284,24 @@ describe PostAction do end end - describe 'when a user votes for something' do - it 'should increase the vote counts when a user votes' do + describe 'when a user likes something' do + it 'should increase the like counts when a user votes' do expect { - PostAction.act(codinghorror, post, PostActionType.types[:vote]) + PostAction.act(codinghorror, post, PostActionType.types[:like]) post.reload - }.to change(post, :vote_count).by(1) + }.to change(post, :like_count).by(1) end it 'should increase the forum topic vote count when a user votes' do expect { - PostAction.act(codinghorror, post, PostActionType.types[:vote]) + PostAction.act(codinghorror, post, PostActionType.types[:like]) post.topic.reload - }.to change(post.topic, :vote_count).by(1) + }.to change(post.topic, :like_count).by(1) + + expect { + PostAction.remove_act(codinghorror, post, PostActionType.types[:like]) + post.topic.reload + }.to change(post.topic, :like_count).by(-1) end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 6b84eff2e6..f2ac723e6e 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1724,4 +1724,21 @@ describe Topic do expect(@topic_status_event_triggered).to eq(true) end + + + it 'allows users to normalize counts' do + + topic = Fabricate(:topic, last_posted_at: 1.year.ago) + post1 = Fabricate(:post, topic: topic, post_number: 1) + post2 = Fabricate(:post, topic: topic, post_type: Post.types[:whisper], post_number: 2) + + Topic.reset_all_highest! + topic.reload + + expect(topic.posts_count).to eq(1) + expect(topic.highest_post_number).to eq(post1.post_number) + expect(topic.highest_staff_post_number).to eq(post2.post_number) + expect(topic.last_posted_at).to be_within(1.second).of (post1.created_at) + end + end diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb index 5410f69eed..cd51c5ff76 100644 --- a/spec/models/topic_tracking_state_spec.rb +++ b/spec/models/topic_tracking_state_spec.rb @@ -20,7 +20,7 @@ describe TopicTrackingState do user = Fabricate(:user) post - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) CategoryUser.create!(user_id: user.id, @@ -30,12 +30,12 @@ describe TopicTrackingState do create_post(topic_id: post.topic_id) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) TopicUser.create!(user_id: user.id, topic_id: post.topic_id, last_read_post_number: 1, notification_level: 3) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) end @@ -62,18 +62,18 @@ describe TopicTrackingState do TopicUser.change(user.id, post2.topic_id, tracking) TopicUser.change(user.id, post3.topic_id, tracking) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(3) end it "correctly gets the tracking state" do - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) post.topic.notifier.watch_topic!(post.topic.user_id) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) row = report[0] @@ -84,18 +84,18 @@ describe TopicTrackingState do expect(row.user_id).to eq(user.id) # lets not leak out random users - expect(TopicTrackingState.report(post.user_id)).to be_empty + expect(TopicTrackingState.report(post.user)).to be_empty # lets not return anything if we scope on non-existing topic - expect(TopicTrackingState.report(user.id, post.topic_id + 1)).to be_empty + expect(TopicTrackingState.report(user, post.topic_id + 1)).to be_empty # when we reply the poster should have an unread row create_post(user: user, topic: post.topic) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) - report = TopicTrackingState.report(post.user_id) + report = TopicTrackingState.report(post.user) expect(report.length).to eq(1) row = report[0] @@ -111,7 +111,7 @@ describe TopicTrackingState do post.topic.category_id = category.id post.topic.save - expect(TopicTrackingState.report(post.user_id)).to be_empty - expect(TopicTrackingState.report(user.id)).to be_empty + expect(TopicTrackingState.report(post.user)).to be_empty + expect(TopicTrackingState.report(user)).to be_empty end end From 2a19e8f2390d0d67cb5e8352a6e44013916d1062 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 17:42:27 +1100 Subject: [PATCH 054/122] UX: improve topic composition on mobile - tighten up space used for composer body - stop collapsing and expanding so much --- .../discourse/components/composer-body.js.es6 | 7 +++ .../discourse/lib/safari-hacks.js.es6 | 56 ++++++++++++++----- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index ec3bc602cd..faee0ef401 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -76,6 +76,13 @@ export default Ember.Component.extend({ } }, + @observes('composeState') + disableFullscreen() { + if (this.get('composeState') !== Composer.OPEN) { + positioningWorkaround.blur(); + } + }, + didInsertElement() { this._super(); const $replyControl = $('#reply-control'); diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 1aa215f534..03b6dfff3a 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -7,6 +7,8 @@ function applicable() { } let workaroundActive = false; +let composingTopic = false; + export function isWorkaroundActive() { return workaroundActive; } @@ -22,27 +24,38 @@ function positioningWorkaround($fixedElement) { var done = false; var originalScrollTop = 0; + positioningWorkaround.blur = function(evt) { + if (workaroundActive) { + done = true; + + $('#main-outlet').show(); + $('header').show(); + + fixedElement.style.position = ''; + fixedElement.style.top = ''; + fixedElement.style.height = ''; + + $(window).scrollTop(originalScrollTop); + + if (evt) { + evt.target.removeEventListener('blur', blurred); + } + workaroundActive = false; + } + }; + var blurredNow = function(evt) { if (!done && _.include($(document.activeElement).parents(), fixedElement)) { // something in focus so skip return; } - done = true; - - $('#main-outlet').show(); - $('header').show(); - - fixedElement.style.position = ''; - fixedElement.style.top = ''; - fixedElement.style.height = ''; - - $(window).scrollTop(originalScrollTop); - - if (evt) { - evt.target.removeEventListener('blur', blurred); + if (composingTopic) { + return false; } - workaroundActive = false; + + positioningWorkaround.blur(evt); + }; var blurred = _.debounce(blurredNow, 250); @@ -73,7 +86,20 @@ function positioningWorkaround($fixedElement) { fixedElement.style.top = '0px'; - const height = Math.max(parseInt(window.innerHeight*0.6), 350); + let ratio = 0.6; + let min = 350; + + composingTopic = false; + + if ($('#reply-control select.category-combobox').length > 0) { + composingTopic = true; + // creating a topic, less height + ratio = 0.54; + min = 300; + } + + const height = Math.max(parseInt(window.innerHeight*ratio), min); + fixedElement.style.height = height + "px"; // I used to do this, but it seems like we don't need to with position From 7b5b255168978a056c70bb2088b2515915155a48 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 17:42:27 +1100 Subject: [PATCH 055/122] UX: improve topic composition on mobile - tighten up space used for composer body - stop collapsing and expanding so much --- .../discourse/components/composer-body.js.es6 | 7 +++ .../discourse/lib/safari-hacks.js.es6 | 56 ++++++++++++++----- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index ec3bc602cd..faee0ef401 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -76,6 +76,13 @@ export default Ember.Component.extend({ } }, + @observes('composeState') + disableFullscreen() { + if (this.get('composeState') !== Composer.OPEN) { + positioningWorkaround.blur(); + } + }, + didInsertElement() { this._super(); const $replyControl = $('#reply-control'); diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 1aa215f534..03b6dfff3a 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -7,6 +7,8 @@ function applicable() { } let workaroundActive = false; +let composingTopic = false; + export function isWorkaroundActive() { return workaroundActive; } @@ -22,27 +24,38 @@ function positioningWorkaround($fixedElement) { var done = false; var originalScrollTop = 0; + positioningWorkaround.blur = function(evt) { + if (workaroundActive) { + done = true; + + $('#main-outlet').show(); + $('header').show(); + + fixedElement.style.position = ''; + fixedElement.style.top = ''; + fixedElement.style.height = ''; + + $(window).scrollTop(originalScrollTop); + + if (evt) { + evt.target.removeEventListener('blur', blurred); + } + workaroundActive = false; + } + }; + var blurredNow = function(evt) { if (!done && _.include($(document.activeElement).parents(), fixedElement)) { // something in focus so skip return; } - done = true; - - $('#main-outlet').show(); - $('header').show(); - - fixedElement.style.position = ''; - fixedElement.style.top = ''; - fixedElement.style.height = ''; - - $(window).scrollTop(originalScrollTop); - - if (evt) { - evt.target.removeEventListener('blur', blurred); + if (composingTopic) { + return false; } - workaroundActive = false; + + positioningWorkaround.blur(evt); + }; var blurred = _.debounce(blurredNow, 250); @@ -73,7 +86,20 @@ function positioningWorkaround($fixedElement) { fixedElement.style.top = '0px'; - const height = Math.max(parseInt(window.innerHeight*0.6), 350); + let ratio = 0.6; + let min = 350; + + composingTopic = false; + + if ($('#reply-control select.category-combobox').length > 0) { + composingTopic = true; + // creating a topic, less height + ratio = 0.54; + min = 300; + } + + const height = Math.max(parseInt(window.innerHeight*ratio), min); + fixedElement.style.height = height + "px"; // I used to do this, but it seems like we don't need to with position From 2793e7bebcba541dfee218ba37719804373bc41c Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 17:56:01 +1100 Subject: [PATCH 056/122] UX: have webkit safari mobile stop with inner shadows http://stackoverflow.com/questions/3062968/remove-textarea-inner-shadow-on-mobile-safari-iphone --- app/assets/stylesheets/common/foundation/base.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index b4267e703f..9ba9ee4295 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -98,3 +98,7 @@ pre code { #offscreen-content { display: none; } + +input { + -webkit-appearance: none; +} From 9a685c64ee86682bc200f609bcfd7fdbb0950b82 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 17:56:01 +1100 Subject: [PATCH 057/122] UX: have webkit safari mobile stop with inner shadows http://stackoverflow.com/questions/3062968/remove-textarea-inner-shadow-on-mobile-safari-iphone --- app/assets/stylesheets/common/foundation/base.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index b4267e703f..9ba9ee4295 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -98,3 +98,7 @@ pre code { #offscreen-content { display: none; } + +input { + -webkit-appearance: none; +} From f57feb2a4b3607df4f694db6ce1d7f1ea55e5448 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 18:19:39 +1100 Subject: [PATCH 058/122] regression composing topics on desktop caught by smoke test --- .../javascripts/discourse/components/composer-body.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index faee0ef401..d3c1e96d2e 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -78,7 +78,7 @@ export default Ember.Component.extend({ @observes('composeState') disableFullscreen() { - if (this.get('composeState') !== Composer.OPEN) { + if (this.get('composeState') !== Composer.OPEN && positioningWorkaround.blur) { positioningWorkaround.blur(); } }, From d5f4f2f5c19db9135873f690c7bd68b6e9d59d70 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 2 Dec 2016 18:19:39 +1100 Subject: [PATCH 059/122] regression composing topics on desktop caught by smoke test --- .../javascripts/discourse/components/composer-body.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index faee0ef401..d3c1e96d2e 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -78,7 +78,7 @@ export default Ember.Component.extend({ @observes('composeState') disableFullscreen() { - if (this.get('composeState') !== Composer.OPEN) { + if (this.get('composeState') !== Composer.OPEN && positioningWorkaround.blur) { positioningWorkaround.blur(); } }, From a93c59abbf70a3441b7997d0c240445b2f9e832b Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Thu, 1 Dec 2016 23:53:07 -0800 Subject: [PATCH 060/122] UX: normalize mobile styles w/desktop --- app/assets/stylesheets/mobile/compose.scss | 5 ++++- app/assets/stylesheets/mobile/login.scss | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index 088aa7d0cd..f4b149a618 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -16,7 +16,10 @@ display: none !important; // can be removed if inline JS CSS is removed from com input { background: $secondary; color: $primary; - border-color: blend-primary-secondary(15%); + padding: 4px; + border-radius: 3px; + box-shadow: inset 0 1px 1px rgba(0,0,0, .3); + border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); } #reply-control { diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index bcc1a06424..9359b1c47f 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -19,7 +19,11 @@ color: dark-light-choose(scale-color($primary, $lightness: 35%), scale-color($secondary, $lightness: 65%)); } label { float: left; display: block; } - textarea, input, select {font-size: 1.143em; clear: left; margin-top: 0; } + textarea, input, select { + font-size: 1.143em; + clear: left; + margin-top: 0; + } td { padding: 4px; } } From bc0a8142fe6e0e441adc53c400c3996344df6a27 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 2 Dec 2016 16:27:47 +0800 Subject: [PATCH 061/122] PERF: Only show members count on group page. --- .../discourse/controllers/group.js.es6 | 9 +++------ .../javascripts/discourse/models/group.js.es6 | 4 ---- .../javascripts/discourse/routes/group.js.es6 | 6 ------ app/controllers/groups_controller.rb | 17 ----------------- spec/controllers/groups_controller_spec.rb | 13 ------------- 5 files changed, 3 insertions(+), 46 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index 2c72b5475a..b7382ed98c 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -38,12 +38,9 @@ export default Ember.Controller.extend({ }; }, - @observes('counts') - countsChanged() { - const counts = this.get('counts'); - this.get('tabs').forEach(tab => { - tab.set('count', counts.get(tab.get('name'))); - }); + @observes('model.user_count') + _setMembersTabCount() { + this.get('tabs')[0].set('count', this.get('model.user_count')); }, @observes('showing') diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 4566c154ab..299cda4b68 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -166,10 +166,6 @@ Group.reopenClass({ }); }, - findGroupCounts(name) { - return ajax("/groups/" + name + "/counts.json").then(result => Em.Object.create(result.counts)); - }, - find(name) { return ajax("/groups/" + name + ".json").then(result => Group.create(result.basic_group)); }, diff --git a/app/assets/javascripts/discourse/routes/group.js.es6 b/app/assets/javascripts/discourse/routes/group.js.es6 index 51724c18b4..3df20f794b 100644 --- a/app/assets/javascripts/discourse/routes/group.js.es6 +++ b/app/assets/javascripts/discourse/routes/group.js.es6 @@ -14,12 +14,6 @@ export default Discourse.Route.extend({ return { name: model.get('name').toLowerCase() }; }, - afterModel(model) { - return Group.findGroupCounts(model.get('name')).then(counts => { - this.set('counts', counts); - }); - }, - setupController(controller, model) { controller.setProperties({ model, counts: this.get('counts') }); } diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index a39bdb3c98..92dec3cce6 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -7,23 +7,6 @@ class GroupsController < ApplicationController render_serialized(find_group(:id), GroupShowSerializer, root: 'basic_group') end - def counts - group = find_group(:group_id) - - counts = { - posts: group.posts_for(guardian).count, - topics: group.posts_for(guardian).where(post_number: 1).count, - mentions: group.mentioned_posts_for(guardian).count, - members: group.users.count, - } - - if guardian.can_see_group_messages?(group) - counts[:messages] = group.messages_for(guardian).where(post_number: 1).count - end - - render json: { counts: counts } - end - def posts group = find_group(:group_id) posts = group.posts_for(guardian, params[:before_post_id]).limit(20) diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index b926e4d6c6..5cd791ba18 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -25,19 +25,6 @@ describe GroupsController do end end - describe "counts" do - it "returns counts if it can be seen" do - xhr :get, :counts, group_id: group.name - expect(response).to be_success - end - - it "returns no counts if it can not be seen" do - group.update_columns(visible: false) - xhr :get, :counts, group_id: group.name - expect(response).not_to be_success - end - end - describe "posts" do it "ensures the group can be seen" do Guardian.any_instance.expects(:can_see?).with(group).returns(false) From d4fd493901a11bdfef8cfa78ee709810eb8627bc Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Fri, 2 Dec 2016 00:46:37 -0800 Subject: [PATCH 062/122] Revert "UX: have webkit safari mobile stop with inner shadows" This reverts commit 9a685c64ee86682bc200f609bcfd7fdbb0950b82. --- app/assets/stylesheets/common/foundation/base.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index 9ba9ee4295..b4267e703f 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -98,7 +98,3 @@ pre code { #offscreen-content { display: none; } - -input { - -webkit-appearance: none; -} From 77d9c17328f0c2e435e7f42f6a99d7f4667cba6e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 2 Dec 2016 16:47:49 +0800 Subject: [PATCH 063/122] FIX: Number emoji images were blank. --- public/images/emoji/apple/eight.png | Bin 0 -> 7833 bytes public/images/emoji/apple/five.png | Bin 0 -> 7400 bytes public/images/emoji/apple/four.png | Bin 0 -> 6941 bytes public/images/emoji/apple/nine.png | Bin 0 -> 7781 bytes public/images/emoji/apple/one.png | Bin 0 -> 6364 bytes public/images/emoji/apple/seven.png | Bin 0 -> 6646 bytes public/images/emoji/apple/six.png | Bin 0 -> 7804 bytes public/images/emoji/apple/three.png | Bin 0 -> 7599 bytes public/images/emoji/apple/two.png | Bin 0 -> 7153 bytes public/images/emoji/emoji_one/eight.png | Bin 0 -> 1411 bytes public/images/emoji/emoji_one/five.png | Bin 0 -> 1276 bytes public/images/emoji/emoji_one/four.png | Bin 0 -> 989 bytes public/images/emoji/emoji_one/nine.png | Bin 0 -> 1388 bytes public/images/emoji/emoji_one/one.png | Bin 0 -> 894 bytes public/images/emoji/emoji_one/seven.png | Bin 0 -> 1079 bytes public/images/emoji/emoji_one/six.png | Bin 0 -> 1369 bytes public/images/emoji/emoji_one/three.png | Bin 0 -> 1327 bytes public/images/emoji/emoji_one/two.png | Bin 0 -> 1248 bytes public/images/emoji/google/eight.png | Bin 0 -> 671 bytes public/images/emoji/google/five.png | Bin 0 -> 1968 bytes public/images/emoji/google/four.png | Bin 0 -> 1559 bytes public/images/emoji/google/nine.png | Bin 0 -> 770 bytes public/images/emoji/google/one.png | Bin 0 -> 1218 bytes public/images/emoji/google/seven.png | Bin 0 -> 570 bytes public/images/emoji/google/six.png | Bin 0 -> 2060 bytes public/images/emoji/google/three.png | Bin 0 -> 2031 bytes public/images/emoji/google/two.png | Bin 0 -> 1813 bytes public/images/emoji/twitter/eight.png | Bin 0 -> 557 bytes public/images/emoji/twitter/five.png | Bin 0 -> 491 bytes public/images/emoji/twitter/four.png | Bin 0 -> 458 bytes public/images/emoji/twitter/nine.png | Bin 0 -> 488 bytes public/images/emoji/twitter/one.png | Bin 0 -> 292 bytes public/images/emoji/twitter/seven.png | Bin 0 -> 456 bytes public/images/emoji/twitter/six.png | Bin 0 -> 525 bytes public/images/emoji/twitter/three.png | Bin 0 -> 544 bytes public/images/emoji/twitter/two.png | Bin 0 -> 537 bytes public/images/emoji/win10/eight.png | Bin 0 -> 820 bytes public/images/emoji/win10/five.png | Bin 0 -> 660 bytes public/images/emoji/win10/four.png | Bin 0 -> 701 bytes public/images/emoji/win10/nine.png | Bin 0 -> 718 bytes public/images/emoji/win10/one.png | Bin 0 -> 561 bytes public/images/emoji/win10/seven.png | Bin 0 -> 638 bytes public/images/emoji/win10/six.png | Bin 0 -> 740 bytes public/images/emoji/win10/three.png | Bin 0 -> 689 bytes public/images/emoji/win10/two.png | Bin 0 -> 726 bytes 45 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/images/emoji/apple/eight.png b/public/images/emoji/apple/eight.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ba8ed5cf211a737d560fe46654a9f2236663b8b0 100644 GIT binary patch literal 7833 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 literal 0 HcmV?d00001 diff --git a/public/images/emoji/apple/five.png b/public/images/emoji/apple/five.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..97fcc34bf7f11cc571cac9761caa1af61c6f467e 100644 GIT binary patch literal 7400 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 literal 0 HcmV?d00001 diff --git a/public/images/emoji/apple/one.png b/public/images/emoji/apple/one.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..fbd9bcf59283682363686ccd9487beb48562970c 100644 GIT binary patch literal 6364 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 AiU0rr literal 0 HcmV?d00001 diff --git a/public/images/emoji/apple/six.png b/public/images/emoji/apple/six.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0c7618b6ca95a1d0c4b5d7c6ae3f2310ca686eb2 100644 GIT binary patch literal 7804 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 literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/eight.png b/public/images/emoji/emoji_one/eight.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3b8024f65c939b9d9aabae241fbe95c39572366c 100644 GIT binary patch literal 1411 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 LNkvXXu0mjfU-Q3) literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/nine.png b/public/images/emoji/emoji_one/nine.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..61af7b15539953e6e5fb63afe219c832c46d1f91 100644 GIT binary patch literal 1388 zcmV-y1(W)TP)fZb`3Um1xJGUO1|WP`U9a*NJ!5T z>2z9Tv)K!&__jnMA%el64piRj^?vzIaAcz^Qk$w5Zf|dkTCFBdPfta!2)?yasfbuC zMnQtDm(L65bE&54MfrSQ^soRFyh;b@Yb_j)$MYH0t;*%H;3BzPj_huTNxHc6ld2)8 zGZVy53er_291MG^(JCAci*~!s4v=Y@LXKi8K!;$Viap<}h{7GYN-C970m7*Ft*U$7 zP(6Hfbi@`=wOXaR*>#l%InF!;7bJ#rHr-4G2X>FD04=HF4`abUpYot36(HBy2cyxb z_z+U59*T4(Cw3DrMQT4I%C9P-^WUfiRm*-hehXpTXF!b!Kis zv|25mfcEw?Fw(ZJKlShg^v9obq|uA6Rq-O3vLJc`Au;TjBA>k}V^XbF=LKj#lO+v) z<`0UqVi-qa+&N3Oac_Bf0ea%zQW*-b!eot{8~t`?OxeycHNy+g^uiRdM_|K=bB`ocBtB7slq%4bq=&xTHraI2l4>hVJUS5?1n|XdLm0dIs|(Hh`2lACE79xbq*QhsUPG>iSc3A6>VUfG4@- zr1i=CM#I3)g=#KD<)=9SvM6>( zh~?EaZM&CyX)-{U%CFb}!e~V8P-u5d=a*bWPI{VGP8z9II&~UWHNim!-A)KAK-jq; z4=G?liXGSEWAon}kkt)u4m-#t$?Mn0{5B4^ynYy{+YrPYsN)Jg5Q_5LSw-@GWc;43 zwbz43f5RJD0)k*9BXZ206#={5KH+~p2>b819g_+OWY@z2mw>|n5Oun2bJGqz7Ww#XqAMi9`f*L0MP#fIyeA*!Li)X`0** zlG_7REEd@UDijL#0mb8Sj^zlj3AWh>1TB?H-1wkp5OBHxgg|B(1{XG{VCq!p>mrL; zbXCsvdcEi2G#U*Q>CkYfViuGDb!Y@nLV2cjn7hXW-;8fp4C_iAQ33jyYM`jdLp?hB zF^bYqCVmkW--d70)y|*4pbpnH;FklnddfFsQE$J)A&Yt;xXZ#n0Q7_QG7}Wn_c5HD zoIGCY?rIAD+Qfw}=e@tzF_H}zhW uVGq|eU;@?aS9HM!%yX=vd>{jP@A3vpRQY$6ad#L10000Gx!JsD;3T=#| zVeRk%z9f^$E3Uz8Z*N~$lRZ%r*OifGR#(tw;p0$n6qq||k^{RB2#3R@b{6GwIm+kr zBWYM$DwU$$-CZY8g`J(9+vkF#E_$Twn42(>NKn0Ar(Ul|<07oJTCGw%9=C%8U#HIs z=cY1~o2XDI&=?D#u&bOP-F1ZH_xo?yg;k|eAzfq`hHbbZCpqPvr`(00u9=WO?I6vt za8UNRY88n@sN3z5ZZb`iR2Aa@9fAdydmeH`u^n|)Hk;)Dq3Ye|!fTEj+-|o?SJi4Y zyD)pk?V#m>LD+&!IoB%8I5_Zoi37CBZhN7EsoWZi7G1W>|1XOAj?@)uwXb!olb|81W^Bj==W5H zy4~N_O9IG70!RP}AOR$R1dsp{Kmter2_OL^fCP{L5bLMx&&;(AG6PAkfuV?spK(G)>+ANbU|$sZ=6eRV)_W1KQi$)3F@^KEXHl zfMDfvnKTn31HQQjWF_lXt3{d!9ZaV+`uc2}S)96@8;u5yyPC}=nsl7t@Xah}0kQ@- z?Ig5k;%_TsCaf82XUaNj9uClRb}-+OhkkU#F`ClQCO(RWwPB4;z4K>psKap$cvI{o zQtsI{^-emR{-$0o%w~fE=&|ErCTOmoU^s@ldAv5<(G>dIF%EP&>-inixL|_dnDBY( zJW^%x)`Wzc0bhqcVCiq-KGYBle44Rxjw_+;;kX9eK#ltmUGM?-IVYuDB)V|@0rl;| U>F*0ZI{*Lx07*qoM6N<$f_d1TSpWb4 literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/seven.png b/public/images/emoji/emoji_one/seven.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..082c0c853f6674dbf367a1c8a306f1532fed5027 100644 GIT binary patch literal 1079 zcmV-71jze|P)Y`vHExpM+;oJRYY+B5^7W zbKBY3p^c4=R-jUDxBJQ;!4Wsz7FOpT7!HR?(=<9bI3S}4bIs@T6buGskg(RCcHvwS zrgINUrBY;I0TjHd6{Kq|;kaC`i?XoF<#J>eC6h^MxFILC$~*VD8$sPSf`e z>~Ym95C~AC(IB%)*L4zAj002z3oiHE;)uc>&8k=|#sNaryTOH5A2+yKt&&+)C=_I2 z_JH%C;Uk0Kf=oF#RhV&buEF9TZfD|AFNCEn9fVQJC1xRKeJ}shD8y`?pEJD*ttW_#i zs_DAa{97Rw&g;qd)ND2hGAN+#RjyRX<`_K&=c#>!+5cD!5JG2SYKHZm;MZ>iSsG9v z7-r$TnwSDSYXhp)bQ&BUV*w@i(gax=(BkqM3&**zM3AKc{m2)n-{v@;n1^1V;weD0 za|^8ZxI8|BEDflrY4q6UV9WZ?T1^3(TU=(nXVv8)$kKo+)hhK54j+Yse1r|70_54) zWW8tFIgiT<(8%~(7SQ+IgaVXE?Xhs|BQLRQR)FT0R#`xwSH2KrX+ZE0`^dQLwL$?( zr86v?0mmr5BPl@G<8XkmqZU$t#wVs&K*4ZC0ovc+XJ1TV`9e_viYJn?{Db7J0Ihrd zETDJOA8}a$nsM@5%jFjurre3SVkv8>0ILpGT0NrVMmy%Z?VjJ!gBvUaN0%IKB9|zOmn{-*NvyTw1D1X#?xGp1m9XB! xoYSpP_HbMSHc&=Cq6=%lKIgEMD=D40egPM8sNZwQ8h!u(002ovPDHLkV1oJG@}>X) literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/six.png b/public/images/emoji/emoji_one/six.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e63c0ca97f130213e3bf5c03e8be2f393e4c58e9 100644 GIT binary patch literal 1369 zcmV-f1*ZCmP)37`k$|v_ z!5Z#);iG>>?klb-9XfI;d4S553Jg3qjm39l0V)&^&lM1QT&kFMv|PSm zEN5hMd3DJvbrIEMpjP|t>&}y|rOf|4n z(Q_|2)-(|W!H#5}9lOdRk;0Kx(nXCxBI&WR>JJVgM)M3V&+2$_BdrWV`; zRt_NKGxx@)m4=Q^T1hi;whPNHWxK~uJfaruv2(%pB8TfSwG|8ZiR3=pW+#!91gP_* zb#7r93-N~2uGQ+~3xwG8U~I|?pz?NB-D~UTF{8&OfXJ<9gk?C_zTh}l*2Js_v73S8 z)3#pVU>L%oO9I40nqP8$2pnt}GD)QfK>UW{+i3`^*7-$d!CoXFKEiEAV#XTJ>iRQ! z;S11KEKZeb^>Xivg~MSIE-35j9T4aWmi~JXRMRx+KP2xDP(Gh0sgld(`Uez^MkSUb zz$Vz%KOktKP#|G~kpbJz2M`=tyte{c;H6?v#f$2dk&8p^~kqvGB0j%Ky<-51p1 zxCUGtsxeZ&XN!8TIvlpB7lN}a{6j$3O%F3cas3sBlh%{R&wD$XLcX?epi8&scU(h* zD+EVl4maUN4~t)4k>Qqxtvw&G^wx2g1%iQ1Hw>TCuORHpFbDkOJFDvtmUlj2a7@CG?zkT8<5@d0c{mXT}($XMunj6kwLNU{!= zEnAXTw%p9GN>!@N^dhw7#G2i|sxK&Zm+g<~?wMKjd}7Jc*(;yVe-;Xbz87{Q91hze zk;uw88r~f~z?W<``>A0te!u^7@vv*+!7X8=n6(77tMG9oI0DSq;vw7IKM;$>NQ^8h z7K>CWmCmK%-Ez4cMWazYP?caXc=4m)h?_18>&YIN$z-V2YSGEb35|>JuFYnXQmGUN z3BFz$7S5N#G}(hHl?siq01Bf@57IZfZ~}qA7hG618VyoK<#L$~H{>L}ymMW4Bd9AT z6|my9|(9mVXo${`0vJG&mQ2G*qlV|G2dLlglg&B9I=v!tOsQOu@BV zl!oK7+soebwp>vQkUtQV=ai)tVuZPn9#o|Ou_2SpWOb(lzfRnd-nS75DF8@P;t^^{ zvX~&wiHf$@YU$0eSwJtsQ3U`QRJGnXu(ZQ>5fIx~AV z37|#SBOcJLNm_DyD8KdEynvkd7XV|ZFl)Cv<^y!=?!Cdp(d~Uqf32?J&nzT|fZ07f zG!-B$PEVJ5lyeReKw1+;=;=m)gM`Jb1xV9+^rzh^1B88T3lM~yBm;DB?x6(;!m@)| zlc^2ppxqwkv$kHc#{J_DsMGCIFcPPAvCewvS|!KKJpF!qa^PTNr%4}@*Y`{c2Ok_E z6#%g#Aeu;xJc*o|RVWA9o%dx?Pv=1 zt%n0$hCRRI5^gXN9E~~LgqJ9bUzsG_%J6mM1D5_t+GL(JZDhKEm=Bqy#q7zS2fHUPnrM#002ovPDHLkV1inia|i$c literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/two.png b/public/images/emoji/emoji_one/two.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0f8b3c87e8648de6cfbd38bc83d59f6ea8ffc33d 100644 GIT binary patch literal 1248 zcmV<61Rwi}P)2;j()Ena()cy*4ptuw?J^pIJ{i`>@xKz4rC2{gEqIXRmZR{Yf|+{?4{5kx0a| zwzf9ekA}L#2l%qNx%shUFriTBGy7#f+b_0kBgM?Jq5T9OdxB$w`PzQT9`^@gu^8Dc zi?Z1)<#M@mX{g)w_BKVMQ5h(6b#?Wt_kv>=`oXrg=!L0NiY&{blamwb7oo1@a+x+Z zHaJM|^{-3sN}C%gdj0VO1)XNEI1|!G;_9Bw4@ni>M%| zD<<@x9HdJ^I2iUsqt*KQIvpJyk!otST4ax6B0yEJ5cNIZi-@8*s;W#TBLaj`?^{uL zT@gLpY&J<%RVo!O%r1*sknf#`&;*&`T$wNv!GYgi5uhni|D&+*&*xguln7Ap-3Jqi z1bs*y9JI)?YIa!f)A5ICxm-sbJQsj!^*SxBuF>856ZFSTAN_XS`@3WZg(92eP9qEy0WqRkEl|$)CW|xEbvCC5KogIjz)8mzxN{F9kXnFt4O8m0H!wxp66nXW|MrwcVuv|1BuzRc7U+Gfio^ZkZ!@A=z6j`7h00S znGDV{%LoksWnS${;XIg}X5gp~2*=Gh+F{|`^pA8tUVyX&6p6*9tp_98?ePciW7x`7 z=Gcch2y|vS!|X#q{KmTrh||pMf(u9@e9_?o;R253n(6s zlj1^JSMPv8S6w-8K`?x5t@?-L&Hxn(1yWV{eBL>rWHPB@Is$xxZ_WY1ip3%+CPW4U zrw2gj$Qq3XDI#ProtEeuWRqEBS6!TOsn3Ku zqwa!XU1%N=pkKKHf{Z-mqa%)yl!i3%^Ju6W>L|;d?>?ap*EQfr*E*5%4V%=fI~+Et z7s6c@5f{*p(#uScT(2=4!=5~T+V#~Gnpqncx^#Pg$3+y-2u_Q++=Q1sEPkm;xEb)Z z=L?qp?YOH7f`Lz$I(?2)Vc5fU4VXan`xRaA0rQ;BQ0~grnd=`Qo-3jU`#19d0000< KMNUMnLSTaKQ(h_n literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/eight.png b/public/images/emoji/google/eight.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..af151748ca968ff5173dcf9fa2590e10e9195bd7 100644 GIT binary patch literal 671 zcmV;Q0$}}#P)Px#32;bRa{vGf6951U69E94oEQKA04h*SR7Lu)Ap5l+ z_?jpD!W;a;8~^|R_me68(NX%WA^-X2_?ajE?!Nxwk@u1*)hPuP00004bW%=J2XVW) zSMqVC0005*Nkl<|MNm&y(1mfF zp;t<(1T7pMYb9iEbI6TVP~Q=;y1eVk&{qL+J=|o5tg_RdQ88|^d0ni=XWYb6`rz~m zH_h`@(ke%|X_l^*S1`g&0-EPf6{eZm#B!3ClTCNLJ*;60Q=eD9D74_J<7_*Q-ZJD3 z!}cy4f`~DWV((byJcg<%unW*mIdq|bVwDLSItx#{Jap(h$tnxk77>b$XRCesSu-!| zNv5o}b$efLn~=Iq@osNP0E0eyQ!P2HPv>DMBKwHO5l7ELrTa!@({s1|#N8K90+3^4X002ovPDHLk FV1fpMC{F+Y literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/five.png b/public/images/emoji/google/five.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2ea1be6607b12a399d3c0cd4c47b1f3926890b0a 100644 GIT binary patch literal 1968 zcmZ`)XH=9&7k;sQlpqk1CLmQ7L|H79HHsiB5CvAifSpxVG)h1gBqW+BAR4hOx={qA zixe9cASe=8dR=6Ru!0CumEN{Z`CfkId_TT9cjn&bnR}<4^URq)7#=RliaLq}K`6Vq zI(w1cb)m~ICdZ>2cZ8E}NuZ;LNStme%`q0=Uqixw ztxD(LxJ;K!BcUYMSe$4k$u<^o&BaM(BqP3OCi;^?lG1!5afYcR+k|8m3jfxXP(ZmBZ1us?aZs)a1^yV$gK`bn z5rh#$L)ozFJnFrHr}=2C6pg<{V9YD1Onr%n30#LgV)L3EBCp6WFCdvU%5AqpUI*LBU!LDCn`%&2PJ*cw< zJVWx-7y&GsLbLy%uPXU3_-pJsNH~NlX$KJ&v#+T2nO?LFay5-5em=3cfO#W23y?Whe$lviIEhG z`$64i*mVxicSB(~Ct<@@(CCOp3(;^MY&(d-92zY_y&Tx_6Xbis_ovXneb{{geZB{qc7j@KG?EYB z{Qz6NQExo@bQMijVc_uqYyxtsmy);Z1t7DIWN1T0!mFvkU$~Cbsw7`lvXTsIU+Gxiy#(9x;Z=g`0*ZhNRE8N z)cwz-8^Q_kWNmIORy|2@_?Cr$sE?a!D z@b2*xq4q}47pbYKk!30OhGgdgQw-&m)_xV*WJPrd3ZHmK8(4l33eB0<3i5q>ZasS* zlqN9TJHPx!%&R^L$3n%^EHES8UiWIKq59Uq=JbOKGj{3^)bm2PPRW_7eR?d_K1%eP zM6Y+MY4k-L$EL(xJ3el!n3~(+@`@89SO+hy;9IEP_d2B@zvNZxm8>9YD0@t}%h6D; zGL2y=AA6}G%82&;_T3Mg2QPN4Kh(51{HwnCGJ0?KewFLP*93M9f6wuX+QX%p@v|dw zH*Uv7$<8o#KRWWe$(hB^(k@2ZQGTN;@i}&?S{u00vC5aTPM8>u$uHNjSEO9nX%@oQ zdc5e${6Iu)hjSv+AV+^mjp-Rig;=3(g_*lXVV1!fn`P$Z>fKyH?#Z?&=H|4Kk-bX$ zHC1UlltT>9W_$RvD_iO>6f?3v*|f2mVt;1z>Bj4ADY|MRCkMYPC{6uSE+h@jRHfB6 zF16ziTW5?>el_H(w8nXD9*KLw+hVy-=k4i87KK$6Ai1tQ>^o@kG*L^VuatJ$eN@N~ zU*=p-Pb~^?SY+9-U7k?ZzN2usjA^W*ZDXy)SgA1>H27eh-PR8=!jkLJg%d>k<%IjM z#Ti}h4VN`?*7{Prcr7O;j3gXdU=KPMTa>_AgtX}@@#jxf)2=#%OmcL%>vS5NrYF_1 zR;e4UcKueB{j#>WdDTrlht}L;yNQ1DfVs^jZPRyGC%feq*E82+drHnfv|x)qp1-Y`)PFcLeZHLM;?19qeB-55aD-F6&-$Wg%C@!VQYI^U z9Bk*d3mbf$tnmBiLV5pTP|SY2ZCmimU;dU~9=SGL!%_e4RDh}Ul8;l}C6x3oVq&Sm zQx&xb3C%U0T=V{cDM^Fx-dw=Rd)h3X=u+p|DVdXPVJX5bR=8Y|rI8$MR-mCEW%lN2Y zoLEWu>ZsmuVVC_X`VPhOubJLRq9EHc+i?)=_Oq^P$?)Hd)g)+LJMjO11Ta z1^-hJ6dZ8!c;x>t*twLhOcrP_?BE@IJS>74$|4*Cf{(Hc+=7^aEH4%_Ao5Hdi~KnW N!j10X{KUyW>c7QFEieE8 literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/four.png b/public/images/emoji/google/four.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..32d830771a2d4e857cc1d61c66b8ad9f18d459c7 100644 GIT binary patch literal 1559 zcmZ`(X;f236n+SlC|FyBFGLB7f^zTvM&N@35bL&Z{EBPzk2$kbMBpczwa*dHz}?3k!|tUS1A!sJdeM9@53J-hZ4?3f{XD)8hQ&Ey zSbQRe=@1n^f?+Xi3{wPQ80RvESsl4r=D!xhX3_mN1$v?cP-CsTP1ocxbw!SvYfcFM zEAJxmDO;(4l3?}rj_bU7O18YuRO`= zE)pGlf@t;y6||Yob&hc47O7Jb%|1lO5fXgC5r3HlYN+7FS~%4P>gaGNhlB*teu%_B z1KBD#*#c_q;J{@RT!2WbNT0)}kt-FZMIHDtx1XIL7CaAT6oxc(NONe(N#Jf-upCm~F4qXK@4*8`Oevt#&8c^o|2eXOyKZEM|L|ZUuTn@W` zBk^(K?N&Hm3soQCmp4RrB2@Rnsdu2B38#CYx(`mZ!_hl%>OH9)f$ARkDIYv{fxRh& zd>_%g33eSPPswb0Yi7kL0e4M<}=mli}-G~4QNYjMSR}?WYL+k>&fJ7^VEqn3muVXJ{?xp zMif|Bnaj`59oU1ydci+>ab4L`sTTsXkMMRxW0>*08P&tGug*uCchQ^c9=N@B?7e!| zJm>jF6M+v6|71PP$d5WD&-F929Q(zj4wm!Flwb$!=Y zK8*eFX_?lia-CB%E{;;K@b1yC3VAg6$(z>nqBNEERs6xgXBkP<)Y2f4DSJ4rFNU#A z@Q2BEX}jYSnQY9Iy){u^;M^P6EKl&YZ%8XzBfXDkMpr`$#Pg*oXH4qwJr`FeknZFQm&Ods7z=rCmS~T zwR&ve?Uirhx;=j?^`tPCA7s+)%SBZ4S=!9Ul!S?t46Z>UC_X*6C_KiElGuJ5g;z_@ zQ4(&btP|a&0!MSbylEdUg%RGJe%mz6gqk$*D6cgPKNl~cY*BvQ+P~-N*}Ignjw`oK z#y*~?Vker}=9?YW@CJ+gDdIH$LX|YtWc_6Fg(g`*Va0Pc!>hT)@Y+~ax<4i6dO_ii z0dcC|HpL4izpImKuk|=~f7$Nn$lmw&tn&M0}@Rc0cD@T28KI)TLwa?QqHiuPD{AQ#*O1 z(Riim_<`4HIfS1vQ#Z7)Y*wk?p`LKI%*#^mT=c|)G|b3S|IC)e{jpfIyF+Eb-e7%6 zq~}qk>SAiMUXaGX>*j@|$V^U{V^zen$ympV-0%#vv`mrbc2S5xB;@Rm5+Z|r$97)1 zl`0M5g`Xd#s6Qh!O+tL6;Nkh2#5$3#R{T?n0rV> fu+ZK+TyQ|h7YahekxfGML1UP=r!Tj}Bk1HmTgx#7 literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/nine.png b/public/images/emoji/google/nine.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..589d508d5afcab9e2dfbb7f0e7c25ce3b7735f8f 100644 GIT binary patch literal 770 zcmV+d1O5DoP)Px#LQqUpMf$HG{K6aj!W;LKDfpQu{K6aj!W;a(9Qv#w z_?svH|Ns2K82YRs_?ajE;g0|N?*8q({KqZ-_1XQ*KmFKk{nAy+7-i-F000VfQchC` zy8r*xTe<$2Y}dyGDF6Tf32;bRa{vGf6951U69E94oEQKA0y9ZOK~z}7t(e)igCGn= zx!Bg$SZ4nJS1;ODz#>hAJo>QL$v_4fW9)hxd4r9&D_iZ}gjrGdmBZU@4y0?d9zM>) z@L+!(^bEsrVPg+H!B`hP9YAkA?Vt~*5wz)P1w~FH=!plxW<}SYI1q5{omkCT&7tO7 z1q9j)Bd3>1sf7PVjTn$uAIb-&{IPHBP>$GPR+P{{1X(o|O<58MjyQp&DIk5wnI=I2 zi7YV2WZ`mnkTprBoVV7yM50Lo{sR;RNvA_ihq_La=#U`LCUO$`Mp8)Ez{^r#V*-o? z9LQ~gJS4Ao`#gCZD2B-BthUT|km`y=AsCBqR8Ul;+7xkDh-#JttZTkhF==&S6-6IZ zEaJp7G>EI|iHIo20y_)OL`n?q=$I*VTN#0r3 z5h$S5MOB_^L{P@bCpHNbMq2T`l4F$Z0fM|AmG=_0TaO4JZw?*@t&Z}w&3Mkwfqb?m zNykru@&*t%a1!Ky_=R^(q(cDCg8~U#~Pw(D^Hvv2H^5R7ecWRVVXvk7zgV6SmOwA za+72!0F>|mDAfUgA*yr?fNcT*EZG2vssQlXdaPyLD&*TIT`QL$0by8}MS^>){aM8Vi*OgAdW+jsM|`#6+hrVGM@1R>S)WI8aFRgxP-$AUges-Y962!$A!> za+I99LD`;D#0-T`LQ@*i!L(NL2?LAh35R`2@XvhmNjdqdm7E)(@YfU?#4POJK(w(O z7w8UwLnWl8o}9iz*)0?aVSfgWN>zLc+9?vF5M@fIu$Sbd0Y3hgg7fg+MtDa;7`a4; z7wk_bZV2H6HAT#kmIi93pPDgI^MAp+DHJ|MVXxucRKmcdY%ho|e|RqoJ}ko9*hI%W z_!VDAqdJ%4Eo{^utbv1HWux{wT2#%&oBV7ixYnQe_LD)_MSuJG0DCRhS`iYFqZgsa z@YTvv(@;jkIjebkk^BCsgPs_yai9O9E&)NcYC%8m1#L2B_<@;8>>1HShO|i z6Ab_^9*#)^M~-P%;+I64RGQ#<*TMY-zgq&NNb{jUDA6Xjy2D^sc79TUee zMLlgEepEqU7M(dX7%OVE)fFhDkE%rbo*Vu zsOS@(aR>CqQ_nqjtT4HK%5Kc-emmwtL!7p!WigD;4K? z+e@CR${7s&?#l~(M8>jZ+3_?c7Rt}hjc=;<=8ri4Uh`XM(1O|klV-h%f&bzn&eF9j zC7OcfrGoDm7M~9n7z+xDIk%2SR-V*Qpt=0ilYhn^vOIiMyLOdrHM4Fbx!L~>CwY;wUP|2QlV9ea*I?bfe3-{{V+i^3b`Og6eScz z#-P1OAc)R@m;YnfQm9mKF8+VR2MZ-jkb&tqA+2z;cDtfT1;on292F;diy}`YQz?|i TU-qidUjjg~BsHNvE_?UC&|yn1 literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/seven.png b/public/images/emoji/google/seven.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..8a1fe0417cb0de8479b5296446b46daa4ff075cc 100644 GIT binary patch literal 570 zcmV-A0>%A_P)Px#JWxzjMf$HG_?{*F!W;kp|M;0F{K6aj!5jR&9Qv&x z_?jp8lPUVFA^4am{n~W*k}3VqO8)M=|M=qm1(GzG%>lm2djmfJ0ATFBQ;hFNIA%~W2Q?gy)6YC@xzzhOKO>IN8|;IK z9e9g!eUyLAU)zM|80u%!^kq4zOYLXR{ji9?# zwB(GSU<6%~>7dL#N11>k`#`==E>}%JuDxVdK=EF#S`39+GAWeGhe!-vlclJDs#Oc2 zm@0=z3{|TZLiMU;Q2h`Ip-`_{2DOq&pkK+H0?Msq5~x+R1PYBqB!Ze%3!vCe#)sNf z3!uh*6+y9GH6NmqaUrT|HWa92T!=VCY=}?nXH-zF{zwWD!%@k7QKV}w~xU-*zF%t1`dt@gM~BgpKOzn12A^Ve)jLf2UiPPAIjng+W-In07*qo IM6N<$f}-a8!~g&Q literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/six.png b/public/images/emoji/google/six.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2cf9e489f389c422a504777e15c63f23e35d6fbb 100644 GIT binary patch literal 2060 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 literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/two.png b/public/images/emoji/google/two.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..1de23db789ea800b9c058e5b7b7b95996f0c7cf0 100644 GIT binary patch literal 1813 zcmZ`)XH=9&7k&|lfWQYz6IilU5l}?RE{G+dLQqLoX+cPg6bVJK zOAv*`1Sx*fc9*um;!05wP(bR^LD(`gZ(e@od_TT>X6D}K&U5FUGv}Ez>0X}BN($Nv z7=|gix;Xj}-erBtNfT?>ZihI+$wbzhEREP@0G2MAD#vkzhBI-I04=tI7IQd~Lv+QF7zzGH zW4F-weYDhxi)R3rik4oXc@FG954hHVqYno!<6;4PeHgwuiLVaAK_+04;7~Gr?T@Da zz!5+*HIV0q7P*k;ibnI%$PL)#0a`84$8>z{BWR)5^}BJ+cT0{lB7y+T{&zW$qN!881Y|Z$x9NUw{^|j zrAR8j6yBk%<(Y^cY!jF1i=G&W?o-yXO<(J594FMN7whBcOL)Q*j@B&6tNyZZU0n?C zsji-W(F-4_YpV8Ss*D~Zwxz<{ogJM+YTFVBARprrcpk$xZCRgESm7-#43pM&b+q#f zV9&mtINoimwQ<4WM$!guiCXRsnO$Q%1-ZkGNjFOr?PR78>&mJ(UCXHU9vEr<#%sW; zAw6M7wdx!EKG|sMYOTANrHO_rMa-Vdmd$kN-~zS#PeivJ8ufnwWZ&5|xn` zpWy#-{GHun>E||eOxs7*dL>HAi~h;K@?x}%Fq0#ZL!o;bXv%DpRAl9*KjbYZ|Es0T zCzE9R|61}ALjtG@S9?>_1S*wyXuQ3g;BK9>Bc1NoHK|qf&5oy<@*D$)d7Ecvf4tT# zOku)}iWLq@+A~@#lS?jP`&%MUjQlzLR8fvXX(^7;a3y6$m_524D>M9y_d%X@LEyW5 zWmYji+$v$~K4re`Qznq%F^xP~pH%i9;$_8DcbFPwWE2<2WEbousXB}YMWwahv&B}H$! zJG-xhe1b1OZYkAY^D~&Q3X}{cTrg(r6cwF0I#{iY94_tLO8zTJlV5Tti^ep_jo+Nz>1DLiuKIm;PFn=Gpfjz^iemM=!3eIPU2g_(++XWMN*v{dCrw(o-roWbc&AC!`}IXU@mZ>U@&V?_SA97fPwLq0uJl@_RV}&?`j7k|8ESoN zZ#FUZrC;c2{l%K25rxP9vTEe-sa-vr#FoKw^LFcA{yJuP)q5^QMl6-*S%)$N@-s6E zwgU5M^0mzew>(@uCC%LMcSFris<>>Lcsi6=r+`>`Xe`4z{2YUDn5D(8y=E3vV)$9m ztoQ7)-c2K7YYPi%0DSg81!tn^Q4tsaf5AanCncg_>v{*@=!n?(&~psTjvgJxAiJIk bjb!*RLg^RJwlRqB3Bz2SJRQsJLz4ahQa#Q) literal 0 HcmV?d00001 diff --git a/public/images/emoji/twitter/eight.png b/public/images/emoji/twitter/eight.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..90f774c3920e8aec591d21e6fdb7e2aa9d964093 100644 GIT binary patch literal 557 zcmV+|0@D47P)XbP zh{HOF!#Rh;I*7wIhR8dJ!~g&PO_Ry*_WRoD^q|J#Xrt1Ewc5Sc>r>w$a{vGU26R$R zQvs39EA$T;%zS1500F5ppY>IH8()7 zq_Q>)`9#eOPqf zibJ;v2;Ir;`VbVo?V!fuM2^R}^%0x^000GaQchCRm%kF>e%$XgK3Y^EHq&oroavtD-FAzSXSjP9uvOcHK#GEaq@IaA^_%@rf@ zb)T@sJLN!8(f$}Nn)#Xz|002ovPDHLkV1nwX-iQDI literal 0 HcmV?d00001 diff --git a/public/images/emoji/twitter/four.png b/public/images/emoji/twitter/four.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..77de1b164b3f33a246807dd23ac5f45cfe337512 100644 GIT binary patch literal 458 zcmV;*0X6=KP)C7n$c{$x3xu|39^lcP+CvjgK|F9Wgf&@n=niivotQ;e?6 zAx0N?NxZ0YjIK?lh}}TX>-85xc~3+GK#emr2dHC2_ad5jL#>Ej1G*E@7|={Y1u0M& z(XE7d1A2tq2Gk=!Igc*^x^d(02=VxUkXv(Y0V;iN0ea#kAvF3@l9A7e1!x+|O-4{m4&ZV zzOuXca;A`~c~G?>s)k0@8mXErRlBBZ{1h$ZH}r~svK@(ZP)XbP zh{HOE!#aq=I)}qHipV>N!~g&PYo^l8;qZ&O-0k%GQI*Q9&F0kUr#%1w00wkYPE!Gq zE7iRXnm4I90004JNklfCC^rdaQu&CamV$Psk zJ8{RL%0)K}s$EpjC^i>Ilh1|uFeOp4sXcB$QVtrnm#%Dbmn5p}_Y=zzn%PWJP##33 zErCX+DAVZ7lqtE>=>Iv1ssnwxB~dQvxaharJq3DBqYu|yGv_Il9J+PSyLjf1d#fq? z-i^a=OQ+soN_b#AO)8iYHF_F*8hDhzE>Xzz;oXmN)Bpm_@Y8U08b1x8_0TB2C~HtB zG5SWci9j`xHbi9Fej`g%59lirGqn{DK)Loz>CeT{UfHA5dhCr;B5V$MLt< zHu4^F;9(6&XI4>i^YC{*uibLc=OD1mo>0D&9_)ME;G)na}BF)(76|j7550)rsNCf$?d8OSa9LQf*;zi zBvNb}uLQkP4BE`8e6@*7YsG^9i@2@UoW8qf@%>8+llSlG>)0RCm0i0&ULe$Yg_Cor n@~V`;(q8SlqA%Jhx}R7I1jY9DmhYJlbT@;itDnm{r-UW|u_ksB literal 0 HcmV?d00001 diff --git a/public/images/emoji/twitter/seven.png b/public/images/emoji/twitter/seven.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..6547329774da5fd672d7fe10a7eaeb0195fbcd74 100644 GIT binary patch literal 456 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 A&;S4c literal 0 HcmV?d00001 diff --git a/public/images/emoji/win10/one.png b/public/images/emoji/win10/one.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eba530a82c8e8610b54ff067897aae313afc4b83 100644 GIT binary patch literal 561 zcmV-10?z%3P)Px#Fi=cXMMrQ<0kr-Frt|=|{{giA0Ji@Ew*LUN{{XiC z@b>yo&HoLL{oB#j-2y{|TQvloksK9^lI*PndL;wH*i%CR5 zR7l6o);&wZKo|$`6m8>Zp;YG^M95M}-6M2x6r|QsM1rFZ?h*x`l(ep%i>nEO;F>Rx zgovwxI*U-cIyiW_zDO@Gca&KCkI9d_2kz~Wq+D~-+*Z|kqr6cmk5kok{Se@wH8eXA z20`E?#)BF)$B+UB>Wrdg%gI7;M2rk6=*|hkFa$c^9B{HoP=SZ`2rBW=DM1UDl+fo z6hbvoB=OKzgf=sYLE8{%S3&6px#zT@DJiatkU_GF4D=gCAwS%Q3yO+{n~_0D(R`@{ z!P2l3lh^i#Gpv-WQL9#IkknOGU2nGXZ47?^r1_@XQ=X=i00000NkvXXu0mjfX?Of+ literal 0 HcmV?d00001 diff --git a/public/images/emoji/win10/seven.png b/public/images/emoji/win10/seven.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..c1329dd77345d0a1506dbb49e43555b9ed00a94d 100644 GIT binary patch literal 638 zcmV-^0)hRBP)Px#JWxzjMMrQ<0Ji@Cv;P3L{{XiC0=EAEw*LXN{spG= z0JZ<`_4-iF{|%4iJIDVXzW>|s`uzR=k=^{d==*Wh{S?8@l>h($3UpFVQvg94|C#V} z+y1)lggh$%00GKLL_t(o!|j;el7k=&h1Gz6NLb+g-*o}4x~)nuUi4xQcBbu&-y|oH z(Nxvfs%~46G(>ghl8Ycp4x-*unl}88sFRddWa$7CJ5C2saQ7b}z_5ZCoD|b5yE=Tw z><#NDtawGCM%X1MfT?cFj3>E(Bo_n=2>wAg%|@-fwa%F&XRTLQ4pPpHKc5<{Dg)to ztbj852~d=Ax*#=kK^ol|=-`N;jqdtzMiEzwL&LHuCjMDdDI&!Nop6dl`h-&o(y0QT z-H$MFEWfe2!^)R~=E5TzbXKrZ(1k%|Aa}T<-$6r`<5Oaq4Vs&Q1vbcCRICIvC+2|- zGM7%n_Bb*Sw&K|qDgfblem06g`G9DkIUoaUkk1E11DSk4D@bHdz2}3{rXGE;Da2cW)u)*H&Q@&-Sa;{?WNb*;uAogW)Tr&9{^d> zE}}++@Qm36i}E0_#^NI&qK4gD_IH6AE@&NUa6xTFeq_5)571O=+4`yPZ&CVb+6`G6 z8GZ-#L9ndlI@T1|q8@e=q!`jo?HrnW+Z|H0qpRO0W1^Gq3CK7I+h$_JCh<~RfA6sG Y7mGVmi`+OOzyJUM07*qoM6N<$f`uR)Y5)KL literal 0 HcmV?d00001 diff --git a/public/images/emoji/win10/six.png b/public/images/emoji/win10/six.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..8e4b23b3ba0f5e024901a855f81b92be9de06001 100644 GIT binary patch literal 740 zcmVPx#OHfQyMMrQ<0Ji@Cw*LUL{{XfB0k{7Ew*LgE_5ij2 z0<`@Aw*T+-`BBaP4vynV%m1w8`!~k_bk+P7y#L4R`U#uukKFs;@%doS{rvv^*Sz<& z0000AbW%=J0Bu1UtMH8e#^C0K3-JH|0wGC6K~z}7&6w+wgCGor)#44b+`RPtpL9XM zsue-f+39hAw4K>+!bu32Ec;m%d8LH38mIP5r4`mHo2uAhD)qZyN>8IsKVdzYz!<$T zj}~LrrD;cM(m5Sd3v{b1)Mw#V@(haUKV~JgQ!XTgfod(Ng@Q^8Dxn~3LHOT5f_()f zMncH^1(19xQMlQhkk^ZFFo+Q?3Qs>>Knx6;7Y+v(q?j~k5^T}NWwR5 zTtE>Z85K|%)Wro92GN+5Z9t9B>Ls8NgF!r^s24Q>5RJ1s2of>7BL-x0_Jo6sMHmjE z;YJh(G98WcnL$8H$9I~>E*Z4tELAW^fRMMt?occV#O%LQI<2TV1%$<#G%>sIcnZk4 zWV6rcWlYzgeHoeR^z7g}AbT!J0S4KE;4qIiN8Ei7KW;T+Qvil!uR_ z9QxkZY~Hee1LU&yfP$**OK-<{7PyT@X~6iAt!b} Wg0)T#uQH(k0000Px#LQqUpMMrQ<0Ji@Cw*LUO{{XlD0JZ-Bwf_LM{{XiC z0Ji_|_WDxI{|%1hImZ7Ly#EHG@^jSuujKpR@%hK=`j+1N{r>)$wM_en%SR9M69nA>)QAPhyv!8?MW_Wysi+^n#a2*j?=LyykW z=w2a`1LXDkVD(+sHAZivA)?yX(DVVMt6M_27UL{KoDed2oLW&BEs{c2KBo|))mRYu2Ymv_l?x8Ug&cQnl$V3nas2wxxQGPM<%Hy; zFNIop8AwMIYGGv{m7`5im1?mqj>L;V0n_Ci3sNp)gJNh95nxCeAYwvVB7f%i(Pl#8 zc#wdS^oJmLXjKZxRxm+>Z0Sin$c{{)L3UYNF1wmT4G z^e}D#I3x(-(zizmE+rs{g+{xUM|AS+I10`YJ=y=tiocrnes7^1>Gfm1w_}56fP_hX zM$zslh6JgUxjbQp7LX_!}*T5 ze;8zD0Tp7CQVIc{Mx19i=wsGnp#C`b&&9!UN^13xTq^VS-^;%JVRY+1nR$b82dbcLvNnQF*P^(qd&1H-2LVc*;Z}oix X#?V}+X-ZJT00000NkvXXu0mjfp8i6L literal 0 HcmV?d00001 diff --git a/public/images/emoji/win10/two.png b/public/images/emoji/win10/two.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5a7e4819477bb5776e1a04cd8d8734dcbff7cdb9 100644 GIT binary patch literal 726 zcmV;{0xA88P)Px#OHfQyMMrQ<0Jr}DwEqCM{{XcA0JZ)AxBmdP{{XfB z1gZA`wf_LN|L^trQO*Aij^q=(|2xP32%hgK!T)pB{nzjMmEQch=llHq{`L6#cCYjv z0000BbW%=J0HhAbM&ytweEwepI9B-p00I(8L_t(o!`0Y}j;kOL2H>rCJ0i%%`urJ%6^%rpB)@C{liY{%(r4tuRka*WsTpjmu+A(^bWr(l#{!=h8;3FU0oftsg<;7d>ef2~FT z5hRtFwbrvyf&fXSmltMxdkscpLTor?S(evgcoeEH4$Z?yX6c10KV&|kLGuhq>h(;; z);Wn8Z6%Wd;d~&Du5i~tkpo?I)zBesdq;;{lLQ@78^q|4*dRrR*tZ~+0F~*GphNcN zlVph7yUla-#^Z5#3XpYRZsgQ~_Xonst zBR0%HgrprYEr}3!PdEld$V3a22B~;~k{}j)#wJ4UN(>e{1#$xxDNr0r8n6^-jw~Hg zA(QOvP(v7an<4-S=Wr^deGXId4kRxJj5DRLrfyfG&fhaU3zyk8xafAD-GM+4-k4hu z^EwwdOb7yLt{Zi=a1TOjq0~F3XC-*xf_#P_j{9$X9=)=kXBl-wSxSKQgVs)5g(0GZcn>e zWu}A`DciR#t*!>!SZ}b5N4M}*uPGnZ=N?<-(q5);`SrcNe}9k4*HdM;zyJUM07*qo IM6N<$f;ezZGynhq literal 0 HcmV?d00001 From e51574bea0a9e5d3a5e7238713558f21dd3f12d6 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 2 Dec 2016 17:17:03 +0800 Subject: [PATCH 064/122] FIX: No need to fetch the model. --- app/models/web_hook.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 289938329e..9884bbf52e 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -41,11 +41,11 @@ class WebHook < ActiveRecord::Base end def self.enqueue_topic_hooks(event, topic, user=nil) - WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category&.id, event_name: event.to_s) + WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category_id, event_name: event.to_s) end def self.enqueue_post_hooks(event, post, user=nil) - WebHook.enqueue_hooks(:post, post_id: post.id, topic_id: post&.topic&.id, user_id: user&.id, category_id: post&.topic&.category_id, event_name: event.to_s) + WebHook.enqueue_hooks(:post, post_id: post.id, topic_id: post&.topic_id, user_id: user&.id, category_id: post&.topic&.category_id, event_name: event.to_s) end %i(topic_destroyed topic_recovered).each do |event| From fa395bb43c17a0e2310592243104bcea775e345a Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Fri, 2 Dec 2016 10:33:12 -0500 Subject: [PATCH 065/122] fix colour bug in summary email --- app/views/user_notifications/digest.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 061f90d16e..59a9bb1ce1 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -302,7 +302,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <% if @other_new_for_you.present? %> -
<%=t 'user_notifications.digest.more_new' %>
+
<%=t 'user_notifications.digest.more_new' %>
<%= digest_custom_html("above_popular_topics") %> From 9db36858237f190658157a262fc2af50225be9ba Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Fri, 2 Dec 2016 13:31:12 -0500 Subject: [PATCH 066/122] fix another case of double user name in digest --- app/views/user_notifications/digest.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 59a9bb1ce1..28036ce8ad 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -265,7 +265,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
<% if post.user %>
<%= post.user.username -%>
- <% if SiteSetting.enable_names? && post.user.name && post.user.name.downcase != post.user.username %> + <% if SiteSetting.enable_names? && post.user.name && post.user.name.downcase != post.user.username.downcase %>

<%= post.user.name -%>

<% end %> <% end %> From dafd1453d61cb716e0cc35ba39633a4ada6c750f Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Fri, 2 Dec 2016 15:58:14 -0500 Subject: [PATCH 067/122] FIX: topic list filters for bookmarked, posted, and read now work with tag filter --- app/controllers/tags_controller.rb | 27 ++++++++---------------- spec/controllers/tags_controller_spec.rb | 6 ++++++ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index dad7ee9512..f72c1ede90 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -8,7 +8,14 @@ class TagsController < ::ApplicationController before_filter :ensure_tags_enabled skip_before_filter :check_xhr, only: [:tag_feed, :show, :index] - before_filter :ensure_logged_in, only: [:notifications, :update_notifications, :update] + before_filter :ensure_logged_in, except: [ + :index, + :show, + :tag_feed, + :search, + :check_hashtag, + Discourse.anonymous_filters.map { |f| :"show_#{f}"} + ].flatten before_filter :set_category_from_params, except: [:index, :update, :destroy, :tag_feed, :search, :notifications, :update_notifications] def index @@ -40,30 +47,14 @@ class TagsController < ::ApplicationController end end - # TODO: move all this to ListController Discourse.filters.each do |filter| define_method("show_#{filter}") do @tag_id = params[:tag_id] @additional_tags = params[:additional_tag_ids].to_s.split('/') - page = params[:page].to_i list_opts = build_topic_list_options - query = TopicQuery.new(current_user, list_opts) - - results = query.send("#{filter}_results") - - if @filter_on_category - category_ids = [@filter_on_category.id] - - unless list_opts[:no_subcategories] - category_ids += @filter_on_category.subcategories.pluck(:id) - end - - results = results.where(category_id: category_ids) - end - - @list = query.create_list(:by_tag, {}, results) + @list = TopicQuery.new(current_user, list_opts).public_send("list_#{filter}") @list.draft_key = Draft::NEW_TOPIC @list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC) diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb index 7c2e7aff06..75696f18cc 100644 --- a/spec/controllers/tags_controller_spec.rb +++ b/spec/controllers/tags_controller_spec.rb @@ -81,6 +81,12 @@ describe TagsController do expect(response).to be_success expect(assigns(:list).topics).to include(t) end + + it "can filter by bookmarked" do + log_in(:user) + xhr :get, :show_bookmarks, tag_id: tag.name + expect(response).to be_success + end end end From ffae39912fc17bf8164472b183b8aa8568ffe9d6 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 2 Dec 2016 16:41:51 -0500 Subject: [PATCH 068/122] FIX: Remove the old poll view before replacing it --- .../javascripts/initializers/extend-for-poll.js.es6 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index ee9904c014..77a25aaf03 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -88,8 +88,16 @@ function initializePolls(api) { votes[pollName] ); + // Destroy a poll view if we're replacing it + if (_pollViews && _pollViews[pollId]) { + _pollViews[pollId].destroy(); + } + $poll.replaceWith($div); - Em.run.schedule('afterRender', () => pollComponent.renderer.appendTo(pollComponent, $div[0])); + Ember.run.scheduleOnce('afterRender', () => { + pollComponent.renderer.appendTo(pollComponent, $div[0]); + }); + postPollViews[pollId] = pollComponent; }); From a5ca41b36259ef4bf440b926897601bceca24362 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Fri, 2 Dec 2016 16:55:16 -0500 Subject: [PATCH 069/122] FIX: sort by column headings in topic list when filtered by tag --- .../javascripts/discourse/routes/app-route-map.js.es6 | 2 +- .../discourse/routes/tags-intersection.js.es6 | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/discourse/routes/tags-intersection.js.es6 diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 76f3803c9f..a1de0bfc74 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -132,7 +132,7 @@ export default function() { this.route('showCategory' + filter.capitalize(), {path: '/c/:category/:tag_id/l/' + filter}); this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter}); }); - this.route('show', {path: 'intersection/:tag_id/*additional_tags'}); + this.route('intersection', {path: 'intersection/:tag_id/*additional_tags'}); }); this.route('tagGroups', {path: '/tag_groups', resetNamespace: true}, function() { diff --git a/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 b/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 new file mode 100644 index 0000000000..5b0e7d8e9f --- /dev/null +++ b/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 @@ -0,0 +1,9 @@ +import TagsShowRoute from 'discourse/routes/tags-show'; + +export default TagsShowRoute.extend({}); + +// The tags-intersection route is exactly the same as the tags-show route, but the wildcard at the +// end of the route (*additional_tags) will cause a match when query parameters are present, +// breaking all other tags-show routes. Ember thinks the query params are addition tags and should +// be handled by the intersection logic. Defining tags-intersection as something separate avoids +// that confusion. From a1d34c15c7c14883362b9b6a0da5f9b9690622a6 Mon Sep 17 00:00:00 2001 From: M Saiqul Haq Date: Sat, 3 Dec 2016 06:10:21 +0700 Subject: [PATCH 070/122] FIX: filename typo --- .../helpers/{discouse-tag.js.es6 => discourse-tag.js.es6} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/assets/javascripts/discourse/helpers/{discouse-tag.js.es6 => discourse-tag.js.es6} (100%) diff --git a/app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 b/app/assets/javascripts/discourse/helpers/discourse-tag.js.es6 similarity index 100% rename from app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 rename to app/assets/javascripts/discourse/helpers/discourse-tag.js.es6 From 3c02e49b28856aefad5576b229ce755888fe02e4 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Fri, 2 Dec 2016 15:29:17 -0800 Subject: [PATCH 071/122] tweak login dialog for narrow mobile phones --- app/assets/stylesheets/mobile/modal.scss | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index bc1689b97c..13e41e2a25 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -38,15 +38,16 @@ width: 95%; } -// an ember metamorph is inserted between btn's sometimes, breaking this rule, but only on mobile for some reason: -// .modal-footer .btn + .btn { -.modal-footer .btn { +// we need a little extra room on mobile for the +// stuff inside the footer to fit +.modal-footer { + padding-right: 0; +} + +.modal-footer .btn + .btn { margin-right: 5px; margin-bottom: 5px; } -.modal-footer .btn-group .btn + .btn { - margin-left: -1px; -} .modal-header { // we need tighter spacing on mobile for header From 8bcdb668c62a3550602f3488a037886584d31a41 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Sat, 3 Dec 2016 04:24:44 -0800 Subject: [PATCH 072/122] fix login disclaimer alignment --- app/assets/stylesheets/common/base/login.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index b24e1ccc5c..7024567bae 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -30,6 +30,7 @@ $input-width: 220px; .disclaimer { font-size: 0.9em; color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); + clear: both; } .user-field.confirm { From 52749c01217e6d67e878bbd45d016286fe5fda89 Mon Sep 17 00:00:00 2001 From: Mohamad Abras Date: Sat, 3 Dec 2016 17:31:10 +0200 Subject: [PATCH 073/122] imporoving vb4 importer --- script/import_scripts/vbulletin.rb | 72 ++++++++++++++++-------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index 90b943b71e..c53835bc44 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -223,7 +223,7 @@ class ImportScripts::VBulletin < ImportScripts::Base raw = preprocess_post_raw(topic["raw"]) rescue nil next if raw.blank? topic_id = "thread-#{topic["threadid"]}" - @closed_topic_ids << topic_id if topic["open"] == "0" + @closed_topic_ids << topic_id if topic["open"] == 0 t = { id: topic_id, user_id: user_id_from_imported_user_id(topic["postuserid"]) || Discourse::SYSTEM_USER_ID, @@ -244,7 +244,11 @@ class ImportScripts::VBulletin < ImportScripts::Base puts "", "importing posts..." # make sure `firstpostid` is indexed - mysql_query("CREATE INDEX firstpostid_index ON #{TABLE_PREFIX}thread (firstpostid)") + begin + mysql_query("CREATE INDEX firstpostid_index ON #{TABLE_PREFIX}thread (firstpostid)") + rescue Mysql2::Error + puts 'Index already exists' + end post_count = mysql_query("SELECT COUNT(postid) count FROM #{TABLE_PREFIX}post WHERE postid NOT IN (SELECT firstpostid FROM #{TABLE_PREFIX}thread)").first["count"] @@ -469,15 +473,15 @@ class ImportScripts::VBulletin < ImportScripts::Base sql = <<-SQL WITH closed_topic_ids AS ( SELECT t.id AS topic_id - FROM #{TABLE_PREFIX}post_custom_fields pcf - JOIN #{TABLE_PREFIX}posts p ON p.id = pcf.post_id - JOIN #{TABLE_PREFIX}topics t ON t.id = p.topic_id + FROM post_custom_fields pcf + JOIN posts p ON p.id = pcf.post_id + JOIN topics t ON t.id = p.topic_id WHERE pcf.name = 'import_id' AND pcf.value IN (?) ) UPDATE topics SET closed = true - WHERE id IN (SELECT topic_id FROM #{TABLE_PREFIX}closed_topic_ids) + WHERE id IN (SELECT topic_id FROM closed_topic_ids) SQL Topic.exec_sql(sql, @closed_topic_ids) @@ -511,39 +515,39 @@ class ImportScripts::VBulletin < ImportScripts::Base raw = @htmlentities.decode(raw) # fix whitespaces - raw = raw.gsub(/(\\r)?\\n/, "\n") - .gsub("\\t", "\t") + raw.gsub!(/(\\r)?\\n/, "\n") + .gsub!("\\t", "\t") # [HTML]...[/HTML] - raw = raw.gsub(/\[html\]/i, "\n```html\n") - .gsub(/\[\/html\]/i, "\n```\n") + raw.gsub!(/\[html\]/i, "\n```html\n") + .gsub!(/\[\/html\]/i, "\n```\n") # [PHP]...[/PHP] - raw = raw.gsub(/\[php\]/i, "\n```php\n") - .gsub(/\[\/php\]/i, "\n```\n") + raw.gsub!(/\[php\]/i, "\n```php\n") + .gsub!(/\[\/php\]/i, "\n```\n") # [HIGHLIGHT="..."] - raw = raw.gsub(/\[highlight="?(\w+)"?\]/i) { "\n```#{$1.downcase}\n" } + raw.gsub!(/\[highlight="?(\w+)"?\]/i) { "\n```#{$1.downcase}\n" } # [CODE]...[/CODE] # [HIGHLIGHT]...[/HIGHLIGHT] - raw = raw.gsub(/\[\/?code\]/i, "\n```\n") - .gsub(/\[\/?highlight\]/i, "\n```\n") + raw.gsub!(/\[\/?code\]/i, "\n```\n") + .gsub!(/\[\/?highlight\]/i, "\n```\n") # [SAMP]...[/SAMP] - raw = raw.gsub(/\[\/?samp\]/i, "`") + raw.gsub!(/\[\/?samp\]/i, "`") # replace all chevrons with HTML entities # NOTE: must be done # - AFTER all the "code" processing # - BEFORE the "quote" processing - raw = raw.gsub(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } - .gsub("<", "<") - .gsub("\u2603", "<") + raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } + .gsub!("<", "<") + .gsub!("\u2603", "<") - raw = raw.gsub(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } - .gsub(">", ">") - .gsub("\u2603", ">") + raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } + .gsub!(">", ">") + .gsub!("\u2603", ">") # [URL=...]...[/URL] raw.gsub!(/\[url="?([^"]+?)"?\](.*?)\[\/url\]/im) { "[#{$2.strip}](#{$1})" } @@ -551,11 +555,11 @@ class ImportScripts::VBulletin < ImportScripts::Base # [URL]...[/URL] # [MP3]...[/MP3] - raw = raw.gsub(/\[\/?url\]/i, "") - .gsub(/\[\/?mp3\]/i, "") + raw.gsub!(/\[\/?url\]/i, "") + .gsub!(/\[\/?mp3\]/i, "") # [MENTION][/MENTION] - raw = raw.gsub(/\[mention\](.+?)\[\/mention\]/i) do + raw.gsub!(/\[mention\](.+?)\[\/mention\]/i) do old_username = $1 if @old_username_to_new_usernames.has_key?(old_username) old_username = @old_username_to_new_usernames[old_username] @@ -570,7 +574,7 @@ class ImportScripts::VBulletin < ImportScripts::Base } # [QUOTE=]...[/QUOTE] - raw = raw.gsub(/\[quote=([^;\]]+)\](.+?)\[\/quote\]/im) do + raw.gsub!(/\[quote=([^;\]]+)\](.+?)\[\/quote\]/im) do old_username, quote = $1, $2 if @old_username_to_new_usernames.has_key?(old_username) old_username = @old_username_to_new_usernames[old_username] @@ -579,10 +583,10 @@ class ImportScripts::VBulletin < ImportScripts::Base end # [YOUTUBE][/YOUTUBE] - raw = raw.gsub(/\[youtube\](.+?)\[\/youtube\]/i) { "\n//youtu.be/#{$1}\n" } + raw.gsub!(/\[youtube\](.+?)\[\/youtube\]/i) { "\n//youtu.be/#{$1}\n" } # [VIDEO=youtube;]...[/VIDEO] - raw = raw.gsub(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\n//youtu.be/#{$1}\n" } + raw.gsub!(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\n//youtu.be/#{$1}\n" } # More Additions .... @@ -610,7 +614,7 @@ class ImportScripts::VBulletin < ImportScripts::Base def postprocess_post_raw(raw) # [QUOTE=;]...[/QUOTE] - raw = raw.gsub(/\[quote=([^;]+);(\d+)\](.+?)\[\/quote\]/im) do + raw.gsub!(/\[quote=([^;]+);(\d+)\](.+?)\[\/quote\]/im) do old_username, post_id, quote = $1, $2, $3 if @old_username_to_new_usernames.has_key?(old_username) @@ -627,11 +631,11 @@ class ImportScripts::VBulletin < ImportScripts::Base end # remove attachments - raw = raw.gsub(/\[attach[^\]]*\]\d+\[\/attach\]/i, "") + raw.gsub!(/\[attach[^\]]*\]\d+\[\/attach\]/i, "") # [THREAD][/THREAD] # ==> http://my.discourse.org/t/slug/ - raw = raw.gsub(/\[thread\](\d+)\[\/thread\]/i) do + raw.gsub!(/\[thread\](\d+)\[\/thread\]/i) do thread_id = $1 if topic_lookup = topic_lookup_from_imported_post_id("thread-#{thread_id}") topic_lookup[:url] @@ -642,7 +646,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [THREAD=]...[/THREAD] # ==> [...](http://my.discourse.org/t/slug/) - raw = raw.gsub(/\[thread=(\d+)\](.+?)\[\/thread\]/i) do + raw.gsub!(/\[thread=(\d+)\](.+?)\[\/thread\]/i) do thread_id, link = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id("thread-#{thread_id}") url = topic_lookup[:url] @@ -654,7 +658,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [POST][/POST] # ==> http://my.discourse.org/t/slug// - raw = raw.gsub(/\[post\](\d+)\[\/post\]/i) do + raw.gsub!(/\[post\](\d+)\[\/post\]/i) do post_id = $1 if topic_lookup = topic_lookup_from_imported_post_id(post_id) topic_lookup[:url] @@ -665,7 +669,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [POST=]...[/POST] # ==> [...](http://my.discourse.org/t///) - raw = raw.gsub(/\[post=(\d+)\](.+?)\[\/post\]/i) do + raw.gsub!(/\[post=(\d+)\](.+?)\[\/post\]/i) do post_id, link = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id(post_id) url = topic_lookup[:url] From 1db9d17756f7a5704f6f9f81ace155f4d98b684c Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 12:11:46 +1100 Subject: [PATCH 074/122] Make removal of topic columns more resilient to deploys --- app/controllers/application_controller.rb | 26 +++++++++++++++++ db/fixtures/009_users.rb | 2 +- db/fixtures/999_topics.rb | 29 +++++++++++++++++++ .../20161205001727_add_topic_columns_back.rb | 21 ++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20161205001727_add_topic_columns_back.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 795a46dfa4..8323528ce4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -110,6 +110,32 @@ class ApplicationController < ActionController::Base end end + def self.last_ar_cache_reset + @last_ar_cache_reset + end + + def self.last_ar_cache_reset=(val) + @last_ar_cache_reset + end + + rescue_from ActiveRecord::StatementInvalid do |e| + + last_cache_reset = ApplicationController.last_ar_cache_reset + + if e.message =~ /UndefinedColumn/ && (last_cache_reset.nil? || last_cache_reset < 30.seconds.ago) + Rails.logger.warn "Clear Active Record cache cause schema appears to have changed!" + + ApplicationController.last_ar_cache_reset = Time.zone.now + + ActiveRecord::Base.connection.query_cache.clear + (ActiveRecord::Base.connection.tables - %w[schema_migrations]).each do |table| + table.classify.constantize.reset_column_information rescue nil + end + end + + raise e + end + class PluginDisabled < StandardError; end # Handles requests for giant IDs that throw pg exceptions diff --git a/db/fixtures/009_users.rb b/db/fixtures/009_users.rb index d65c8f2ef7..dd8a203e6b 100644 --- a/db/fixtures/009_users.rb +++ b/db/fixtures/009_users.rb @@ -32,7 +32,7 @@ duration = Rails.env.production? ? 60 : 0 if User.exec_sql("SELECT 1 FROM schema_migration_details WHERE EXISTS( SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'users' AND column_name = 'last_redirected_to_top_at' + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_redirected_to_top_at' ) AND name = 'MoveTrackingOptionsToUserOptions' AND created_at < (current_timestamp at time zone 'UTC' - interval '#{duration} minutes') diff --git a/db/fixtures/999_topics.rb b/db/fixtures/999_topics.rb index cfe48615bc..e46419fd20 100644 --- a/db/fixtures/999_topics.rb +++ b/db/fixtures/999_topics.rb @@ -64,3 +64,32 @@ if seed_welcome_topics skip_validations: true, category: staff ? staff.name : nil) end + + + +# run this later, cause we need to make sure new application controller resilience is in place first +duration = Rails.env.production? ? 60 : 0 +if Topic.exec_sql("SELECT 1 FROM schema_migration_details + WHERE EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = 'public' AND table_name = 'topics' AND column_name = 'inappropriate_count' + ) AND + name = 'AddTopicColumnsBack' AND + created_at < (current_timestamp at time zone 'UTC' - interval '#{duration} minutes') + ").to_a.length > 0 + + + Topic.transaction do + STDERR.puts "Removing superflous topic columns!" + %w[ + inappropriate_count + bookmark_count + off_topic_count + illegal_count + notify_user_count +].each do |column| + User.exec_sql("ALTER TABLE topics DROP COLUMN IF EXISTS #{column}") + end + + end +end diff --git a/db/migrate/20161205001727_add_topic_columns_back.rb b/db/migrate/20161205001727_add_topic_columns_back.rb new file mode 100644 index 0000000000..d464c54a9e --- /dev/null +++ b/db/migrate/20161205001727_add_topic_columns_back.rb @@ -0,0 +1,21 @@ +class AddTopicColumnsBack < ActiveRecord::Migration + + # This really sucks big time, we have no use for these columns yet can not remove them + # if we remove them then sites will be down during migration + + def up + add_column :topics, :bookmark_count, :int + add_column :topics, :off_topic_count, :int + add_column :topics, :illegal_count, :int + add_column :topics, :inappropriate_count, :int + add_column :topics, :notify_user_count, :int + end + + def down + remove_column :topics, :bookmark_count + remove_column :topics, :off_topic_count + remove_column :topics, :illegal_count + remove_column :topics, :inappropriate_count + remove_column :topics, :notify_user_count + end +end From bd217bb440367ba20f7e176ad02ef044e74603f9 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 12:21:07 +1100 Subject: [PATCH 075/122] remove ux regression --- app/assets/stylesheets/common/foundation/base.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index 9ba9ee4295..b4267e703f 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -98,7 +98,3 @@ pre code { #offscreen-content { display: none; } - -input { - -webkit-appearance: none; -} From f68194cf8ef916a0f74c8397ac7e9a2ad8b59864 Mon Sep 17 00:00:00 2001 From: Mohamad Abras Date: Mon, 5 Dec 2016 04:16:59 +0200 Subject: [PATCH 076/122] fix nil for vb4 importer --- script/import_scripts/vbulletin.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index c53835bc44..fb1deb292f 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -516,15 +516,15 @@ class ImportScripts::VBulletin < ImportScripts::Base # fix whitespaces raw.gsub!(/(\\r)?\\n/, "\n") - .gsub!("\\t", "\t") + raw.gsub!("\\t", "\t") # [HTML]...[/HTML] raw.gsub!(/\[html\]/i, "\n```html\n") - .gsub!(/\[\/html\]/i, "\n```\n") + raw.gsub!(/\[\/html\]/i, "\n```\n") # [PHP]...[/PHP] raw.gsub!(/\[php\]/i, "\n```php\n") - .gsub!(/\[\/php\]/i, "\n```\n") + raw.gsub!(/\[\/php\]/i, "\n```\n") # [HIGHLIGHT="..."] raw.gsub!(/\[highlight="?(\w+)"?\]/i) { "\n```#{$1.downcase}\n" } @@ -532,7 +532,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [CODE]...[/CODE] # [HIGHLIGHT]...[/HIGHLIGHT] raw.gsub!(/\[\/?code\]/i, "\n```\n") - .gsub!(/\[\/?highlight\]/i, "\n```\n") + raw.gsub!(/\[\/?highlight\]/i, "\n```\n") # [SAMP]...[/SAMP] raw.gsub!(/\[\/?samp\]/i, "`") @@ -542,12 +542,12 @@ class ImportScripts::VBulletin < ImportScripts::Base # - AFTER all the "code" processing # - BEFORE the "quote" processing raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } - .gsub!("<", "<") - .gsub!("\u2603", "<") + raw.gsub!("<", "<") + raw.gsub!("\u2603", "<") raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } - .gsub!(">", ">") - .gsub!("\u2603", ">") + raw.gsub!(">", ">") + raw.gsub!("\u2603", ">") # [URL=...]...[/URL] raw.gsub!(/\[url="?([^"]+?)"?\](.*?)\[\/url\]/im) { "[#{$2.strip}](#{$1})" } @@ -556,7 +556,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [URL]...[/URL] # [MP3]...[/MP3] raw.gsub!(/\[\/?url\]/i, "") - .gsub!(/\[\/?mp3\]/i, "") + raw.gsub!(/\[\/?mp3\]/i, "") # [MENTION][/MENTION] raw.gsub!(/\[mention\](.+?)\[\/mention\]/i) do From 39a524aac87eb33d48787c06c6d2305b48f52d58 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 13:57:09 +1100 Subject: [PATCH 077/122] FEATURE: brotli cdn bypass for assets Allow CDNS that strip out brotli encoding to use brotli regardless --- app/controllers/static_controller.rb | 27 ++++++++++++++++++- app/helpers/application_helper.rb | 10 ++++--- config/routes.rb | 1 + lib/middleware/anonymous_cache.rb | 11 +++++++- .../middleware/anonymous_cache_spec.rb | 10 +++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index d7dee2377b..9da1201c79 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -4,7 +4,7 @@ require_dependency 'file_helper' class StaticController < ApplicationController skip_before_filter :check_xhr, :redirect_to_login_if_required - skip_before_filter :verify_authenticity_token, only: [:cdn_asset, :enter, :favicon] + skip_before_filter :verify_authenticity_token, only: [:brotli_asset, :cdn_asset, :enter, :favicon] PAGES_WITH_EMAIL_PARAM = ['login', 'password_reset', 'signup'] @@ -123,7 +123,32 @@ class StaticController < ApplicationController response.headers["Last-Modified"] = Time.new('2000-01-01').httpdate render text: data, content_type: "image/png" end + end + def brotli_asset + path = File.expand_path(Rails.root + "public/assets/" + params[:path]) + path += ".br" + + # SECURITY what if path has /../ + raise Discourse::NotFound unless path.start_with?(Rails.root.to_s + "/public/assets") + + opts = { disposition: nil } + opts[:type] = "application/javascript" if path =~ /\.js.br$/ + + response.headers["Expires"] = 1.year.from_now.httpdate + response.headers["Content-Encoding"] = 'br' + begin + response.headers["Last-Modified"] = File.ctime(path).httpdate + response.headers["Content-Length"] = File.size(path).to_s + rescue Errno::ENOENT + raise Discourse::NotFound + end + + if File.exists?(path) + send_file(path, opts) + else + raise Discourse::NotFound + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d79ace3f34..e1400e9883 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -46,10 +46,12 @@ module ApplicationHelper end def script(*args) - if SiteSetting.enable_cdn_js_debugging && GlobalSetting.cdn_url - tags = javascript_include_tag(*args, "crossorigin" => "anonymous") - tags.gsub!("/assets/", "/cdn_asset/#{Discourse.current_hostname.tr(".","_")}/") - tags.gsub!(".js\"", ".js?v=1&origin=#{CGI.escape request.base_url}\"") + if GlobalSetting.cdn_url && + GlobalSetting.cdn_url.start_with?("https") && + ENV["COMPRESS_BROTLI"] == "1" && + request.env["ACCEPT_ENCODING"] =~ /br/ + tags = javascript_include_tag(*args) + tags.gsub!("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/") tags.html_safe else javascript_include_tag(*args) diff --git a/config/routes.rb b/config/routes.rb index f465e56a38..770d819945 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -650,6 +650,7 @@ Discourse::Application.routes.draw do delete "draft" => "draft#destroy" get "cdn_asset/:site/*path" => "static#cdn_asset", format: false + get "brotli_asset/*path" => "static#brotli_asset", format: false get "favicon/proxied" => "static#favicon", format: false diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index a79b422566..149ac3f009 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -11,6 +11,7 @@ module Middleware class Helper USER_AGENT = "HTTP_USER_AGENT".freeze RACK_SESSION = "rack.session".freeze + ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING".freeze def initialize(env) @env = env @@ -35,6 +36,14 @@ module Middleware @is_mobile == :true end + def has_brotli? + @has_brotli ||= + begin + @env[ACCEPT_ENCODING].to_s =~ /br/ ? :true : :false + end + @has_brotli == :true + end + def is_crawler? @is_crawler ||= begin @@ -45,7 +54,7 @@ module Middleware end def cache_key - @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}" + @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}" end def cache_key_body diff --git a/spec/components/middleware/anonymous_cache_spec.rb b/spec/components/middleware/anonymous_cache_spec.rb index 8dce0e5bcc..db0c481c4a 100644 --- a/spec/components/middleware/anonymous_cache_spec.rb +++ b/spec/components/middleware/anonymous_cache_spec.rb @@ -45,6 +45,16 @@ describe Middleware::AnonymousCache::Helper do crawler.clear_cache end + it "handles brotli switching" do + helper.cache([200, {"HELLO" => "WORLD"}, ["hello ", "my world"]]) + + helper = new_helper("ANON_CACHE_DURATION" => 10) + expect(helper.cached).to eq([200, {"X-Discourse-Cached" => "true", "HELLO" => "WORLD"}, ["hello my world"]]) + + helper = new_helper("ANON_CACHE_DURATION" => 10, "HTTP_ACCEPT_ENCODING" => "gz, br") + expect(helper.cached).to eq(nil) + end + it "returns cached data for cached requests" do helper.is_mobile = true expect(helper.cached).to eq(nil) From 96183dbf6b5c4688507f3fa4148c681b8d4f552f Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 13:57:35 +1100 Subject: [PATCH 078/122] remove unused site setting, not really needed any more --- config/locales/server.en.yml | 1 - config/site_settings.yml | 2 -- 2 files changed, 3 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f42b6ff8e7..901c636852 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1354,7 +1354,6 @@ en: embed_whitelist_selector: "CSS selector for elements that are allowed in embeds." embed_blacklist_selector: "CSS selector for elements that are removed from embeds." notify_about_flags_after: "If there are flags that haven't been handled after this many hours, send an email to the contact_email. Set to 0 to disable." - enable_cdn_js_debugging: "Allow /logs to display proper errors by adding crossorigin permissions on all js includes." show_create_topics_notice: "If the site has fewer than 5 public topics, show a notice asking admins to create some topics." delete_drafts_older_than_n_days: Delete drafts older than (n) days. diff --git a/config/site_settings.yml b/config/site_settings.yml index 40ff14fda4..76b2959666 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1197,8 +1197,6 @@ uncategorized: notify_about_flags_after: 48 - enable_cdn_js_debugging: false - show_create_topics_notice: client: true default: true From ce36f54dcd570145cd0d34be425692af1771c873 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 11:39:08 +0800 Subject: [PATCH 079/122] Add rake task to clean up orphane Redis keys when a multisite has been removed. --- lib/tasks/redis.rake | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 lib/tasks/redis.rake diff --git a/lib/tasks/redis.rake b/lib/tasks/redis.rake new file mode 100644 index 0000000000..4300f0686c --- /dev/null +++ b/lib/tasks/redis.rake @@ -0,0 +1,30 @@ +task 'redis:clean_up' => ['environment'] do + return unless Rails.configuration.multisite + + dbs = RailsMultisite::ConnectionManagement.all_dbs + dbs << Discourse::SIDEKIQ_NAMESPACE + + regexp = /((\$(?\w+)$)|(^?(?\w+):))/ + + cursor = 0 + redis = $redis.without_namespace + + loop do + cursor, keys = redis.scan(cursor) + cursor = cursor.to_i + + redis.multi do + keys.each do |key| + if match = key.match(regexp) + db_name = match[:message_bus] || match[:namespace] + + if !dbs.include?(db_name) + redis.del(key) + end + end + end + end + + break if cursor == 0 + end +end From 22059d4df978f3337dd4d931b085752f384b2da5 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 11:46:34 +0800 Subject: [PATCH 080/122] Add Rake task to clean up unused multisite Redis keys. --- app/jobs/scheduled/clean_up_redis_keys.rb | 37 +++++++++++++++++++ lib/discourse.rb | 4 +- spec/tasks/redis_spec.rb | 45 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/jobs/scheduled/clean_up_redis_keys.rb create mode 100644 spec/tasks/redis_spec.rb diff --git a/app/jobs/scheduled/clean_up_redis_keys.rb b/app/jobs/scheduled/clean_up_redis_keys.rb new file mode 100644 index 0000000000..442e9fc31e --- /dev/null +++ b/app/jobs/scheduled/clean_up_redis_keys.rb @@ -0,0 +1,37 @@ +module Jobs + class CleanUpRedisKeys < Jobs::Scheduled + every 1.week + + def execute(args) + return unless Rails.configuration.multisite + return unless SiteSetting.clean_up_redis_keys + + dbs = RailsMultisite::ConnectionManagement.all_dbs + dbs << Discourse::SIDEKIQ_NAMESPACE + + regexp = /((\$(?\w+)$)|(^?(?\w+):))/ + + cursor = 0 + redis = $redis.without_namespace + + loop do + cursor, keys = redis.scan(cursor) + cursor = cursor.to_i + + redis.multi do + keys.each do |key| + if match = key.match(regexp) + db_name = match[:message_bus] || match[:namespace] + + if !dbs.include?(db_name) + redis.del(key) + end + end + end + end + + break if cursor == 0 + end + end + end +end diff --git a/lib/discourse.rb b/lib/discourse.rb index f28ad2593c..9f27827f4c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -371,9 +371,11 @@ module Discourse end end + SIDEKIQ_NAMESPACE ||= 'sidekiq'.freeze + def self.sidekiq_redis_config conf = GlobalSetting.redis_config.dup - conf[:namespace] = 'sidekiq' + conf[:namespace] = SIDEKIQ_NAMESPACE conf end diff --git a/spec/tasks/redis_spec.rb b/spec/tasks/redis_spec.rb new file mode 100644 index 0000000000..64e0305e2f --- /dev/null +++ b/spec/tasks/redis_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe "Redis rake tasks" do + let(:redis) { $redis.without_namespace } + + before do + @multisite = Rails.configuration.multisite + Rails.configuration.multisite = true + Discourse::Application.load_tasks + end + + after do + Rails.configuration.multisite = @multisite + end + + describe 'clean up' do + it 'should clean up orphan Redis keys' do + active_keys = [ + '__mb_backlog_id_n_/users/someusername$|$default', + 'default:user-last-seen:607', + 'sidekiq:something:do:something', + 'somekeytonotbetouched' + ] + + orphan_keys = [ + 'tgxworld:user-last-seen:607', + '__mb_backlog_id_n_/users/someusername$|$tgxworld' + ] + + (active_keys | orphan_keys).each do |key| + redis.set(key, 1) + end + + Rake::Task['redis:clean_up'].invoke + + active_keys.each do |key| + expect(redis.get(key)).to eq('1') + end + + orphan_keys.each do |key| + expect(redis.get(key)).to eq(nil) + end + end + end +end From 55b35a05edb82eb946655d2c1268ddd0cccf658c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 11:48:47 +0800 Subject: [PATCH 081/122] FIX: This should not have been checked in. --- app/jobs/scheduled/clean_up_redis_keys.rb | 37 ----------------------- 1 file changed, 37 deletions(-) delete mode 100644 app/jobs/scheduled/clean_up_redis_keys.rb diff --git a/app/jobs/scheduled/clean_up_redis_keys.rb b/app/jobs/scheduled/clean_up_redis_keys.rb deleted file mode 100644 index 442e9fc31e..0000000000 --- a/app/jobs/scheduled/clean_up_redis_keys.rb +++ /dev/null @@ -1,37 +0,0 @@ -module Jobs - class CleanUpRedisKeys < Jobs::Scheduled - every 1.week - - def execute(args) - return unless Rails.configuration.multisite - return unless SiteSetting.clean_up_redis_keys - - dbs = RailsMultisite::ConnectionManagement.all_dbs - dbs << Discourse::SIDEKIQ_NAMESPACE - - regexp = /((\$(?\w+)$)|(^?(?\w+):))/ - - cursor = 0 - redis = $redis.without_namespace - - loop do - cursor, keys = redis.scan(cursor) - cursor = cursor.to_i - - redis.multi do - keys.each do |key| - if match = key.match(regexp) - db_name = match[:message_bus] || match[:namespace] - - if !dbs.include?(db_name) - redis.del(key) - end - end - end - end - - break if cursor == 0 - end - end - end -end From 8a98d617df1953b02b9714bf55d27eeb53ea9321 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 15:10:54 +1100 Subject: [PATCH 082/122] correct headers and add better caching --- app/controllers/static_controller.rb | 1 + app/helpers/application_helper.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 9da1201c79..42d776e99a 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -136,6 +136,7 @@ class StaticController < ApplicationController opts[:type] = "application/javascript" if path =~ /\.js.br$/ response.headers["Expires"] = 1.year.from_now.httpdate + response.headers["Cache-Control"] = 'max-age=31557600, public' response.headers["Content-Encoding"] = 'br' begin response.headers["Last-Modified"] = File.ctime(path).httpdate diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e1400e9883..e3689036be 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -49,7 +49,7 @@ module ApplicationHelper if GlobalSetting.cdn_url && GlobalSetting.cdn_url.start_with?("https") && ENV["COMPRESS_BROTLI"] == "1" && - request.env["ACCEPT_ENCODING"] =~ /br/ + request.env["HTTP_ACCEPT_ENCODING"] =~ /br/ tags = javascript_include_tag(*args) tags.gsub!("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/") tags.html_safe From dc66f6681a911387376a03692ca1ff4c83d294eb Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 16:08:36 +1100 Subject: [PATCH 083/122] add spec for brotli controller, ensure cached correctly --- app/controllers/static_controller.rb | 2 ++ spec/controllers/static_controller_spec.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 42d776e99a..1641c01956 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -145,6 +145,8 @@ class StaticController < ApplicationController raise Discourse::NotFound end + expires_in 1.year, public: true, must_revalidate: false + if File.exists?(path) send_file(path, opts) else diff --git a/spec/controllers/static_controller_spec.rb b/spec/controllers/static_controller_spec.rb index b5b8eb0b74..2be919efa2 100644 --- a/spec/controllers/static_controller_spec.rb +++ b/spec/controllers/static_controller_spec.rb @@ -2,6 +2,18 @@ require 'rails_helper' describe StaticController do + context 'brotli_asset' do + it 'has correct headers for brotli assets' do + FileUtils.mkdir_p(Rails.root + "public/assets/") + File.write(Rails.root + "public/assets/test.js.br", 'fake brotli file') + + get :brotli_asset, path: 'test.js' + + expect(response.status).to eq(200) + expect(response.headers["Cache-Control"]).to match(/public/) + end + end + context 'show' do before do post = create_post From 0fbb3fb02bdc852c82d10f07222418c21531a3d0 Mon Sep 17 00:00:00 2001 From: Erick Guan Date: Fri, 11 Nov 2016 09:16:29 +0100 Subject: [PATCH 084/122] FIX: open login modal fails because of missing parameters --- .../javascripts/discourse/components/login-buttons.js.es6 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index ccaabff239..4c68d1740f 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -1,4 +1,5 @@ import { findAll } from 'discourse/models/login-method'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ elementId: 'login-buttons', @@ -6,9 +7,10 @@ export default Ember.Component.extend({ hidden: Ember.computed.equal('buttons.length', 0), - buttons: function() { - return findAll(this.siteSettings); - }.property(), + @computed + buttons() { + return findAll(this.siteSettings, this.capabilities, this.site.isMobileDevice); + }, actions: { externalLogin: function(provider) { From b45fd21ed9850c05920cb147beb83f45fa131d70 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 13:37:33 +0800 Subject: [PATCH 085/122] FIX: Clean up specs. --- spec/controllers/static_controller_spec.rb | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/spec/controllers/static_controller_spec.rb b/spec/controllers/static_controller_spec.rb index 2be919efa2..f8ba662984 100644 --- a/spec/controllers/static_controller_spec.rb +++ b/spec/controllers/static_controller_spec.rb @@ -4,13 +4,21 @@ describe StaticController do context 'brotli_asset' do it 'has correct headers for brotli assets' do - FileUtils.mkdir_p(Rails.root + "public/assets/") - File.write(Rails.root + "public/assets/test.js.br", 'fake brotli file') + begin + assets_path = Rails.root.join("public/assets") - get :brotli_asset, path: 'test.js' + FileUtils.mkdir_p(assets_path) - expect(response.status).to eq(200) - expect(response.headers["Cache-Control"]).to match(/public/) + file_path = assets_path.join("test.js.br") + File.write(file_path, 'fake brotli file') + + get :brotli_asset, path: 'test.js' + + expect(response.status).to eq(200) + expect(response.headers["Cache-Control"]).to match(/public/) + ensure + File.delete(file_path) + end end end From c2a17826e743104d26078a171b7d14fbacce949f Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 17:22:08 +1100 Subject: [PATCH 086/122] ship highest staff post number to staff --- app/serializers/listable_topic_serializer.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index e8cd1a7f15..bf62e14f68 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -26,6 +26,10 @@ class ListableTopicSerializer < BasicTopicSerializer has_one :last_poster, serializer: BasicUserSerializer, embed: :objects + def highest_post_number + (scope.is_staff? && object.highest_staff_post_number) || object.highest_post_num + end + def liked object.user_data && object.user_data.liked end From 31acd311e59684e4399bbd54d295e2fd88a6fa52 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 29 Nov 2016 16:25:02 +0800 Subject: [PATCH 087/122] FEATURE: Allow group owners to edit group name and avatar flair. --- .../admin/controllers/admin-group.js.es6 | 39 ----------- .../javascripts/admin/templates/group.hbs | 58 +--------------- .../discourse/components/avatar-flair.js.es6 | 20 ++++++ .../components/group-flair-inputs.js.es6 | 50 ++++++++++++++ .../discourse/controllers/edit-group.js.es6 | 20 ++++++ .../discourse/controllers/group-index.js.es6 | 13 +--- .../discourse/controllers/group.js.es6 | 5 ++ .../javascripts/discourse/models/group.js.es6 | 10 ++- .../javascripts/discourse/routes/group.js.es6 | 8 +++ .../components/group-flair-inputs.hbs | 47 +++++++++++++ .../components/user-card-contents.hbs | 6 +- .../javascripts/discourse/templates/group.hbs | 30 +++++--- .../discourse/templates/modal/edit-group.hbs | 10 +++ .../stylesheets/common/admin/admin_base.scss | 32 --------- .../stylesheets/common/base/groups.scss | 50 ++++++++++++-- app/controllers/admin/groups_controller.rb | 43 +++++++----- app/controllers/groups_controller.rb | 34 +++++++-- app/models/group.rb | 6 +- app/serializers/group_show_serializer.rb | 18 ++++- config/locales/client.en.yml | 25 ++++--- .../admin/groups_controller_spec.rb | 8 +-- spec/integration/groups_spec.rb | 69 ++++++++++++++++--- .../serializers/group_show_serializer_spec.rb | 31 +++++++++ .../javascripts/acceptance/groups-test.js.es6 | 8 ++- .../javascripts/controllers/group-test.js.es6 | 19 +++++ .../fixtures/group-fixtures.js.es6 | 3 +- 26 files changed, 453 insertions(+), 209 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/avatar-flair.js.es6 create mode 100644 app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 create mode 100644 app/assets/javascripts/discourse/controllers/edit-group.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/group-flair-inputs.hbs create mode 100644 app/assets/javascripts/discourse/templates/modal/edit-group.hbs create mode 100644 spec/serializers/group_show_serializer_spec.rb create mode 100644 test/javascripts/controllers/group-test.js.es6 diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index e7d53800e7..660fbafd3b 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -1,7 +1,5 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { propertyEqual } from 'discourse/lib/computed'; -import { escapeExpression } from 'discourse/lib/utilities'; -import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ adminGroupsType: Ember.inject.controller(), @@ -37,43 +35,6 @@ export default Ember.Controller.extend({ ]; }.property(), - @computed - demoAvatarUrl() { - return Discourse.getURL('/images/avatar.png'); - }, - - @computed('model.flair_url') - flairPreviewIcon() { - return this.get('model.flair_url') && this.get('model.flair_url').substr(0,3) === 'fa-'; - }, - - @computed('flairPreviewIcon') - flairPreviewImage() { - return this.get('model.flair_url') && !this.get('flairPreviewIcon'); - }, - - @computed('flairPreviewImage', 'model.flair_url', 'model.flairBackgroundHexColor', 'model.flairHexColor') - flairPreviewStyle() { - var style = ''; - if (this.get('flairPreviewImage')) { - style += 'background-image: url(' + escapeExpression(this.get('model.flair_url')) + '); '; - } - if (this.get('model.flairBackgroundHexColor')) { - style += 'background-color: #' + this.get('model.flairBackgroundHexColor') + ';'; - } - if (this.get('model.flairHexColor')) { - style += 'color: #' + this.get('model.flairHexColor') + ';'; - } - return style; - }, - - @computed('model.flairBackgroundHexColor') - flairPreviewClasses() { - if (this.get('model.flairBackgroundHexColor')) { - return 'rounded'; - } - }, - actions: { next() { if (this.get("showingLast")) { return; } diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 0d53645023..b8852002aa 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -4,8 +4,8 @@ {{#if model.automatic}}

{{model.name}}

{{else}} - - {{text-field name="name" value=model.name placeholderKey="admin.groups.name_placeholder"}} + + {{text-field name="name" value=model.name placeholderKey="group.name_placeholder"}} {{/if}} @@ -101,59 +101,7 @@ {{/unless}} {{#unless model.automatic}} -
-
-
- - {{text-field name="flair_url" value=model.flair_url placeholderKey="admin.groups.flair_url_placeholder"}} -
- -
- - {{text-field name="flair_bg_color" class="flair-bg-color" value=model.flair_bg_color placeholderKey="admin.groups.flair_bg_color_placeholder"}} -
- - {{#if flairPreviewIcon}} -
- - {{text-field name="flair_color" class="flair-color" value=model.flair_color placeholderKey="admin.groups.flair_color_placeholder"}} -
- {{/if}} - -
-
- {{i18n 'admin.groups.flair_note'}} -
-
- - {{#if flairPreviewIcon}} -
- -
-
- -
-
- -
-
-
- {{/if}} - - {{#if flairPreviewImage}} -
- -
-
- -
-
-
-
- {{/if}} - -
-
+ {{group-flair-inputs model=model}} {{/unless}}
diff --git a/app/assets/javascripts/discourse/components/avatar-flair.js.es6 b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 new file mode 100644 index 0000000000..4beaa9cdf5 --- /dev/null +++ b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 @@ -0,0 +1,20 @@ +import { observes } from 'ember-addons/ember-computed-decorators'; +import MountWidget from 'discourse/components/mount-widget'; + +export default MountWidget.extend({ + widget: 'avatar-flair', + + @observes('flairURL', 'flairBgColor', 'flairColor') + _rerender() { + this.queueRerender(); + }, + + buildArgs() { + return { + primary_group_flair_url: this.get('flairURL'), + primary_group_flair_bg_color: this.get('flairBgColor'), + primary_group_flair_color: this.get('flairColor'), + primary_group_name: this.get('groupName') + }; + } +}); diff --git a/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 new file mode 100644 index 0000000000..f14d45da63 --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 @@ -0,0 +1,50 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { escapeExpression } from 'discourse/lib/utilities'; + +export default Ember.Component.extend({ + + classNames: ['group-flair-inputs'], + + @computed + demoAvatarUrl() { + return Discourse.getURL('/images/avatar.png'); + }, + + @computed('model.flair_url') + flairPreviewIcon(flairURL) { + return flairURL && flairURL.substr(0,3) === 'fa-'; + }, + + @computed('model.flair_url', 'flairPreviewIcon') + flairPreviewImage(flairURL, flairPreviewIcon) { + return flairURL && !flairPreviewIcon; + }, + + @computed('model.flair_url', 'flairPreviewImage', 'model.flairBackgroundHexColor', 'model.flairHexColor') + flairPreviewStyle(flairURL, flairPreviewImage, flairBackgroundHexColor, flairHexColor) { + let style = ''; + + if (flairPreviewImage) { + style += `background-image: url(${escapeExpression(flairURL)});`; + } + + if (flairBackgroundHexColor) { + style += `background-color: #${flairBackgroundHexColor};`; + } + + if (flairHexColor) style += `color: #${flairHexColor};`; + + return style; + }, + + @computed('model.flairBackgroundHexColor') + flairPreviewClasses(flairBackgroundHexColor) { + if (flairBackgroundHexColor) return 'rounded'; + }, + + @computed('flairPreviewImage') + flairPreviewLabel(flairPreviewImage) { + const key = flairPreviewImage ? 'image' : 'icon'; + return I18n.t(`group.flair_preview_${key}`); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/edit-group.js.es6 b/app/assets/javascripts/discourse/controllers/edit-group.js.es6 new file mode 100644 index 0000000000..01db8c7ad4 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/edit-group.js.es6 @@ -0,0 +1,20 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + saving: false, + + actions: { + save() { + this.set('saving', true); + + this.get('model').save().then(() => { + this.transitionToRoute('group', this.get('model.name')); + this.send('closeModal'); + }).catch(error => { + popupAjaxError(error); + }).finally(() => { + this.set('saving', false); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/group-index.js.es6 b/app/assets/javascripts/discourse/controllers/group-index.js.es6 index 306ff8aa80..162ac3e898 100644 --- a/app/assets/javascripts/discourse/controllers/group-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-index.js.es6 @@ -1,22 +1,11 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; -import computed from 'ember-addons/ember-computed-decorators'; import Group from 'discourse/models/group'; export default Ember.Controller.extend({ loading: false, limit: null, offset: null, - - @computed('model.owners.[]') - isOwner(owners) { - if (this.get('currentUser.admin')) { - return true; - } - const currentUserId = this.get('currentUser.id'); - if (currentUserId) { - return !!owners.findBy('id', currentUserId); - } - }, + isOwner: Ember.computed.alias('model.is_group_owner'), actions: { removeMember(user) { diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index b7382ed98c..f46a3bfe55 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -23,6 +23,11 @@ export default Ember.Controller.extend({ Tab.create({ name: 'messages', requiresMembership: true }) ], + @computed('model.is_group_owner', 'model.automatic') + canEditGroup(isGroupOwner, automatic) { + return !automatic && isGroupOwner; + }, + @computed('model.name') groupName(name) { return name.capitalize(); diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 299cda4b68..68550d2c2b 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -119,13 +119,19 @@ const Group = Discourse.Model.extend({ create() { var self = this; - return ajax("/admin/groups", { type: "POST", data: this.asJSON() }).then(function(resp) { + return ajax("/admin/groups", { type: "POST", data: { group: this.asJSON() } }).then(function(resp) { self.set('id', resp.basic_group.id); }); }, save() { - return ajax("/admin/groups/" + this.get('id'), { type: "PUT", data: this.asJSON() }); + const id = this.get('id'); + const url = this.get('is_group_owner') ? `/groups/${id}` : `/admin/groups/${id}`; + + return ajax(url, { + type: "PUT", + data: { group: this.asJSON() } + }); }, destroy() { diff --git a/app/assets/javascripts/discourse/routes/group.js.es6 b/app/assets/javascripts/discourse/routes/group.js.es6 index 3df20f794b..51731bccb6 100644 --- a/app/assets/javascripts/discourse/routes/group.js.es6 +++ b/app/assets/javascripts/discourse/routes/group.js.es6 @@ -1,4 +1,5 @@ import Group from 'discourse/models/group'; +import showModal from 'discourse/lib/show-modal'; export default Discourse.Route.extend({ @@ -16,5 +17,12 @@ export default Discourse.Route.extend({ setupController(controller, model) { controller.setProperties({ model, counts: this.get('counts') }); + }, + + actions: { + showGroupEditor() { + showModal('edit-group'); + this.controllerFor('edit-group').set('model', this.modelFor('group')); + } } }); diff --git a/app/assets/javascripts/discourse/templates/components/group-flair-inputs.hbs b/app/assets/javascripts/discourse/templates/components/group-flair-inputs.hbs new file mode 100644 index 0000000000..1e75c9655f --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/group-flair-inputs.hbs @@ -0,0 +1,47 @@ +
+
+ + {{text-field name="flair_url" + value=model.flair_url + placeholderKey="group.flair_url_placeholder"}} +
+ +
+ + {{text-field name="flair_bg_color" + class="group-flair-bg-color" + value=model.flair_bg_color + placeholderKey="group.flair_bg_color_placeholder"}} +
+ + {{#if flairPreviewIcon}} +
+ + {{text-field name="flair_color" + class="group-flair-color" + value=model.flair_color + placeholderKey="group.flair_color_placeholder"}} +
+ {{/if}} + +
+ {{i18n 'group.flair_note'}} +
+
+ +
+ +
+
+ +
+ + {{#if flairPreviewImage}} +
+ {{else}} +
+ +
+ {{/if}} +
+
diff --git a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs index 641351cc00..c88b7cc9d6 100644 --- a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs @@ -4,7 +4,11 @@
{{bound-avatar avatar "huge"}} {{#if user.primary_group_name}} - {{mount-widget widget="avatar-flair" args=user}} + {{avatar-flair + flairURL=user.primary_group_flair_url + flairBgColor=user.primary_group_flair_bg_color + flairColor=user.primary_group_flair_color + groupName=user.primary_group_name}} {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index f3f96d4efe..c7be3e1fef 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -16,15 +16,27 @@
-
-

- {{#if model.flair_url}} - - {{mount-widget widget="avatar-flair" args=avatarFlairAttributes}} - - {{/if}} - {{groupName}} -

+
+ +

+ {{#if model.flair_url}} + + {{avatar-flair + flairURL=model.flair_url + flairBgColor=model.flair_bg_color + flairColor=model.flair_color + groupName=model.name}} + + {{/if}} + {{groupName}} +

+
+ + {{#if canEditGroup}} + + {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} + + {{/if}}
{{outlet}} diff --git a/app/assets/javascripts/discourse/templates/modal/edit-group.hbs b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs new file mode 100644 index 0000000000..f467e5e94e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs @@ -0,0 +1,10 @@ +{{#d-modal-body title="group.edit.title" class="edit-group groups"}} +
+ {{group-flair-inputs model=model}} +
+{{/d-modal-body}} + + diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 4a93fedb9a..13893d7cc2 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -696,38 +696,6 @@ section.details { width: 100%; border-color: dark-light-choose(scale-color($primary, $lightness: 75%), scale-color($secondary, $lightness: 25%)); } - .avatar-flair-preview { - position: relative; - width: 45px; - - .avatar-wrapper { - background-color: #f4f4f4; - } - } - .form-horizontal { - .flair-inputs { - margin-top: 30px; - margin-bottom: 30px; - - .flair-left { - float: left; - width: 60%; - input[name=flair_url] { - width: 90%; - } - } - - .flair-right { - float: left; - margin-left: 30px; - } - } - } -} -.row.groups { - input[type='text'].flair-bg-color, input[type='text'].flair-color { - width: 200px; - } } // Customise area diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss index 9146b4bc07..82b79388ac 100644 --- a/app/assets/stylesheets/common/base/groups.scss +++ b/app/assets/stylesheets/common/base/groups.scss @@ -1,9 +1,12 @@ .groups { - .group-header { + .group-header, .group-details { display: table; - } - .group-avatar-flair { + span { + display: table-cell; + vertical-align: middle; + } + .avatar-flair { $size: 40px; @@ -17,8 +20,43 @@ } } - .group-avatar-flair, .group-name { - display: table-cell; - vertical-align: middle; + .group-edit-btn { + margin-left: 5px; + } + + .form-horizontal { + label { + font-weight: bold; + } + + input[type="text"] { + width: 80% !important; + margin-bottom: 10px; + } + + .group-flair-inputs { + display: inline-block; + margin-top: 30px; + margin-bottom: 30px; + + .group-flair-left { + float: left; + } + + .group-flair-right { + float: left; + margin-left: 30px; + } + } + + .avatar-flair-preview { + position: relative; + width: 45px; + + .avatar-wrapper { + background-color: #f4f4f4; + } + } } } + diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index daca310124..e363aff74a 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -36,7 +36,7 @@ class Admin::GroupsController < Admin::AdminController def create group = Group.new - group.name = (params[:name] || '').strip + group.name = (group_params[:name] || '').strip save_group(group) end @@ -44,29 +44,29 @@ class Admin::GroupsController < Admin::AdminController group = Group.find(params[:id]) # group rename is ignored for automatic groups - group.name = params[:name] if params[:name] && !group.automatic + group.name = group_params[:name] if group_params[:name] && !group.automatic save_group(group) end def save_group(group) - group.alias_level = params[:alias_level].to_i if params[:alias_level].present? - group.visible = params[:visible] == "true" - grant_trust_level = params[:grant_trust_level].to_i + group.alias_level = group_params[:alias_level].to_i if group_params[:alias_level].present? + group.visible = group_params[:visible] == "true" + grant_trust_level = group_params[:grant_trust_level].to_i group.grant_trust_level = (grant_trust_level > 0 && grant_trust_level <= 4) ? grant_trust_level : nil - group.automatic_membership_email_domains = params[:automatic_membership_email_domains] unless group.automatic - group.automatic_membership_retroactive = params[:automatic_membership_retroactive] == "true" unless group.automatic + group.automatic_membership_email_domains = group_params[:automatic_membership_email_domains] unless group.automatic + group.automatic_membership_retroactive = group_params[:automatic_membership_retroactive] == "true" unless group.automatic - group.primary_group = group.automatic ? false : params["primary_group"] == "true" + group.primary_group = group.automatic ? false : group_params["primary_group"] == "true" - group.incoming_email = group.automatic ? nil : params[:incoming_email] + group.incoming_email = group.automatic ? nil : group_params[:incoming_email] - title = params[:title] if params[:title].present? + title = group_params[:title] if group_params[:title].present? group.title = group.automatic ? nil : title - group.flair_url = params[:flair_url].presence - group.flair_bg_color = params[:flair_bg_color].presence - group.flair_color = params[:flair_color].presence + group.flair_url = group_params[:flair_url].presence + group.flair_bg_color = group_params[:flair_bg_color].presence + group.flair_color = group_params[:flair_color].presence if group.save Group.reset_counters(group.id, :group_users) @@ -124,7 +124,18 @@ class Admin::GroupsController < Admin::AdminController protected - def can_not_modify_automatic - render json: {errors: I18n.t('groups.errors.can_not_modify_automatic')}, status: 422 - end + def can_not_modify_automatic + render json: {errors: I18n.t('groups.errors.can_not_modify_automatic')}, status: 422 + end + + private + + def group_params + params.require(:group).permit( + :name, :alias_level, :visible, :automatic_membership_email_domains, + :automatic_membership_retroactive, :title, :primary_group, + :grant_trust_level, :incoming_email, :flair_url, :flair_bg_color, + :flair_color + ) + end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 92dec3cce6..918a113848 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,12 +1,28 @@ class GroupsController < ApplicationController - before_filter :ensure_logged_in, only: [:set_notifications, :mentionable] + before_filter :ensure_logged_in, only: [ + :set_notifications, + :mentionable, + :update + ] + skip_before_filter :preload_json, :check_xhr, only: [:posts_feed, :mentions_feed] def show render_serialized(find_group(:id), GroupShowSerializer, root: 'basic_group') end + def update + group = Group.find(params[:id]) + guardian.ensure_can_edit!(group) + + if group.update_attributes(group_params) + render json: success_json + else + render_json_error(group) + end + end + def posts group = find_group(:group_id) posts = group.posts_for(guardian, params[:before_post_id]).limit(20) @@ -152,11 +168,15 @@ class GroupsController < ApplicationController private - def find_group(param_name) - name = params.require(param_name) - group = Group.find_by("lower(name) = ?", name.downcase) - guardian.ensure_can_see!(group) - group - end + def group_params + params.require(:group).permit(:flair_url, :flair_bg_color, :flair_color) + end + + def find_group(param_name) + name = params.require(param_name) + group = Group.find_by("lower(name) = ?", name.downcase) + guardian.ensure_can_see!(group) + group + end end diff --git a/app/models/group.rb b/app/models/group.rb index 9d488ac762..c0c487f4d2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -349,7 +349,11 @@ class Group < ActiveRecord::Base end def add_owner(user) - self.group_users.create(user_id: user.id, owner: true) + if group_user = self.group_users.find_by(user: user) + group_user.update_attributes!(owner: true) if !group_user.owner + else + GroupUser.create!(user: user, group: self, owner: true) + end end def self.find_by_email(email) diff --git a/app/serializers/group_show_serializer.rb b/app/serializers/group_show_serializer.rb index 58358c1bac..dc9e0c7649 100644 --- a/app/serializers/group_show_serializer.rb +++ b/app/serializers/group_show_serializer.rb @@ -1,11 +1,25 @@ class GroupShowSerializer < BasicGroupSerializer - attributes :is_group_user + attributes :is_group_user, :is_group_owner def include_is_group_user? scope.authenticated? end def is_group_user - object.users.include?(scope.user) + !!fetch_group_user + end + + def include_is_group_owner? + scope.authenticated? + end + + def is_group_owner + scope.is_admin? || fetch_group_user&.owner + end + + private + + def fetch_group_user + @group_user ||= object.group_users.find_by(user: scope.user) end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e65c6f928c..486748c215 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1845,6 +1845,21 @@ en: title: "Show the raw source diffs side-by-side" button: ' Raw' + group: + edit: + title: 'Edit Group' + name: "Name" + name_placeholder: "Group name, no spaces, same as username rule" + flair_url: "Avatar Flair Image" + flair_url_placeholder: "(Optional) Image URL or Font Awesome class" + flair_bg_color: "Avatar Flair Background Color" + flair_bg_color_placeholder: "(Optional) Hex color value" + flair_color: "Avatar Flair Color" + flair_color_placeholder: "(Optional) Hex color value" + flair_preview_icon: "Preview Icon" + flair_preview_image: "Preview Image" + flair_note: "Note: Flair will only show for a user's primary group." + category: can: 'can… ' none: '(no category)' @@ -2451,7 +2466,6 @@ en: refresh: "Refresh" new: "New" selector_placeholder: "enter username" - name_placeholder: "Group name, no spaces, same as username rule" about: "Edit your group membership and names here" group_members: "Group members" delete: "Delete" @@ -2459,7 +2473,6 @@ en: delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed." delete_member_confirm: "Remove '%{username}' from the '%{group}' group?" delete_owner_confirm: "Remove owner privilege for '%{username}'?" - name: "Name" add: "Add" add_members: "Add members" custom: "Custom" @@ -2476,14 +2489,6 @@ en: add_owners: Add owners incoming_email: "Custom incoming email address" incoming_email_placeholder: "enter email address" - flair_url: "Avatar Flair Image" - flair_url_placeholder: "(Optional) Image URL or Font Awesome class" - flair_bg_color: "Avatar Flair Background Color" - flair_bg_color_placeholder: "(Optional) Hex color value" - flair_color: "Avatar Flair Color" - flair_color_placeholder: "(Optional) Hex color value" - flair_preview: "Preview" - flair_note: "Note: Flair will only show for a user's primary group." api: generate_master: "Generate Master API Key" diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 6971184dd4..39fc436e3a 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -66,7 +66,7 @@ describe Admin::GroupsController do context ".create" do it "strip spaces on the group name" do - xhr :post, :create, name: " bob " + xhr :post, :create, { group: { name: " bob " } } expect(response.status).to eq(200) @@ -81,7 +81,7 @@ describe Admin::GroupsController do context ".update" do it "ignore name change on automatic group" do - xhr :put, :update, id: 1, name: "WAT", visible: "true" + xhr :put, :update, { id: 1, group: { name: "WAT", visible: "true" } } expect(response).to be_success group = Group.find(1) @@ -92,14 +92,14 @@ describe Admin::GroupsController do it "doesn't launch the 'automatic group membership' job when it's not retroactive" do Jobs.expects(:enqueue).never group = Fabricate(:group) - xhr :put, :update, id: group.id, automatic_membership_retroactive: "false" + xhr :put, :update, { id: group.id, group: { automatic_membership_retroactive: "false" } } expect(response).to be_success end it "launches the 'automatic group membership' job when it's retroactive" do group = Fabricate(:group) Jobs.expects(:enqueue).with(:automatic_group_membership, group_id: group.id) - xhr :put, :update, id: group.id, automatic_membership_retroactive: "true" + xhr :put, :update, { id: group.id, group: { automatic_membership_retroactive: "true" } } expect(response).to be_success end diff --git a/spec/integration/groups_spec.rb b/spec/integration/groups_spec.rb index 396d7df9f4..e7f4e9eb7d 100644 --- a/spec/integration/groups_spec.rb +++ b/spec/integration/groups_spec.rb @@ -1,22 +1,22 @@ require 'rails_helper' describe "Groups" do - describe "checking if a group can be mentioned" do - let(:password) { 'somecomplicatedpassword' } - let(:email_token) { Fabricate(:email_token, confirmed: true) } - let(:user) { email_token.user } - let(:group) { Fabricate(:group, name: 'test', users: [user]) } + let(:password) { 'somecomplicatedpassword' } + let(:email_token) { Fabricate(:email_token, confirmed: true) } + let(:user) { email_token.user } - before do - user.update_attributes!(password: password) - end + before do + user.update_attributes!(password: password) + post "/session.json", { login: user.username, password: password } + expect(response).to be_success + end + + describe "checking if a group can be mentioned" do + let(:group) { Fabricate(:group, name: 'test', users: [user]) } it "should return the right response" do group - post "/session.json", { login: user.username, password: password } - expect(response).to be_success - get "/groups/test/mentionable.json", { name: group.name } expect(response).to be_success @@ -33,4 +33,51 @@ describe "Groups" do expect(response_body["mentionable"]).to eq(true) end end + + describe "group can be updated" do + let(:group) { Fabricate(:group, name: 'test', users: [user]) } + + context "when user is group owner" do + before do + group.add_owner(user) + end + + it "should be able update the group" do + xhr :put, "/groups/#{group.id}", { group: { + flair_bg_color: 'FFF', + flair_color: 'BBB', + flair_url: 'fa-adjust' + } } + + expect(response).to be_success + + group.reload + + expect(group.flair_bg_color).to eq('FFF') + expect(group.flair_color).to eq('BBB') + expect(group.flair_url).to eq('fa-adjust') + end + end + + context "when user is group admin" do + before do + user.update_attributes!(admin: true) + end + + it 'should be able to update the group' do + xhr :put, "/groups/#{group.id}", { group: { flair_color: 'BBB' } } + + expect(response).to be_success + expect(group.reload.flair_color).to eq('BBB') + end + end + + context "when user is not a group owner or admin" do + it 'should not be able to update the group' do + xhr :put, "/groups/#{group.id}", { group: { name: 'testing' } } + + expect(response.status).to eq(403) + end + end + end end diff --git a/spec/serializers/group_show_serializer_spec.rb b/spec/serializers/group_show_serializer_spec.rb new file mode 100644 index 0000000000..5641ccd8d0 --- /dev/null +++ b/spec/serializers/group_show_serializer_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe GroupShowSerializer do + context 'admin user' do + let(:user) { Fabricate(:admin) } + let(:group) { Fabricate(:group, users: [user]) } + + it 'should return the right attributes' do + json = GroupShowSerializer.new(group, scope: Guardian.new(user)).as_json + + expect(json[:group_show][:is_group_owner]).to eq(true) + expect(json[:group_show][:is_group_user]).to eq(true) + end + end + + context 'group owner' do + let(:user) { Fabricate(:user) } + let(:group) { Fabricate(:group) } + + before do + group.add_owner(user) + end + + it 'should return the right attributes' do + json = GroupShowSerializer.new(group, scope: Guardian.new(user)).as_json + + expect(json[:group_show][:is_group_owner]).to eq(true) + expect(json[:group_show][:is_group_user]).to eq(true) + end + end +end diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 3b01016a94..53c1b15745 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -32,7 +32,7 @@ test("Browsing Groups", () => { }); }); -test("Messages tab", () => { +test("Admin Browsing Groups", () => { logIn(); Discourse.reset(); @@ -41,4 +41,10 @@ test("Messages tab", () => { andThen(() => { ok($('.action-list li').length === 5, 'it should show messages tab if user is admin'); }); + + click('.group-edit-btn'); + + andThen(() => { + ok(find('.group-flair-inputs').length === 1, 'it should display avatar flair inputs'); + }); }); diff --git a/test/javascripts/controllers/group-test.js.es6 b/test/javascripts/controllers/group-test.js.es6 new file mode 100644 index 0000000000..822b14dd42 --- /dev/null +++ b/test/javascripts/controllers/group-test.js.es6 @@ -0,0 +1,19 @@ +moduleFor("controller:group"); + +test("canEditGroup", function() { + const GroupController = this.subject(); + + GroupController.setProperties({ + model: { is_group_owner: true, automatic: true } + }); + + equal(GroupController.get("canEditGroup"), false, "automatic groups cannot be edited"); + + GroupController.set("model.automatic", false); + + equal(GroupController.get("canEditGroup"), true, "owners can edit groups"); + + GroupController.set("model.is_group_owner", false); + + equal(GroupController.get("canEditGroup"), false, "normal users cannot edit groups"); +}); diff --git a/test/javascripts/fixtures/group-fixtures.js.es6 b/test/javascripts/fixtures/group-fixtures.js.es6 index d6ea20a239..38dd8d69d8 100644 --- a/test/javascripts/fixtures/group-fixtures.js.es6 +++ b/test/javascripts/fixtures/group-fixtures.js.es6 @@ -7,7 +7,8 @@ export default { "user_count":8, "alias_level":0, "visible":true, - "flair_url": 'fa-adjust' + "flair_url": 'fa-adjust', + "is_group_owner":true } }, "/groups/discourse/counts.json":{ From e0c28d6fd5d2f3bdadb9db60c532e082bcf28471 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 30 Nov 2016 16:11:02 +0800 Subject: [PATCH 088/122] REFACTOR: Stop mixing users page and groups page CSS. --- .../discourse/templates/group-index.hbs | 65 +++++------ .../javascripts/discourse/templates/group.hbs | 59 +++++----- app/assets/stylesheets/common/base/group.scss | 101 ++++++++++++++++++ .../stylesheets/common/base/groups.scss | 62 ----------- app/assets/stylesheets/desktop.scss | 1 + app/assets/stylesheets/desktop/group.scss | 12 +++ app/assets/stylesheets/desktop/user.scss | 44 -------- app/assets/stylesheets/mobile.scss | 1 + app/assets/stylesheets/mobile/group.scss | 23 ++++ .../javascripts/acceptance/groups-test.js.es6 | 4 +- 10 files changed, 201 insertions(+), 171 deletions(-) create mode 100644 app/assets/stylesheets/common/base/group.scss delete mode 100644 app/assets/stylesheets/common/base/groups.scss create mode 100644 app/assets/stylesheets/desktop/group.scss create mode 100644 app/assets/stylesheets/mobile/group.scss diff --git a/app/assets/javascripts/discourse/templates/group-index.hbs b/app/assets/javascripts/discourse/templates/group-index.hbs index 41ad1fd922..bbfe6334d2 100644 --- a/app/assets/javascripts/discourse/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/templates/group-index.hbs @@ -1,43 +1,44 @@ {{#if model}} {{#if isOwner}} -
-
- {{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}} - {{d-button action="addMembers" class="add" icon="plus" label="groups.add"}} -
-
+
+ {{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}} + {{d-button action="addMembers" class="add" icon="plus" label="groups.add"}} +
{{/if}} {{#load-more selector=".group-members tr" action="loadMore"}} - - - - {{#if isOwner}} + - {{/if}} - - {{#each model.members as |m|}} - - - - - {{#if isOwner}} - + + + + + + {{#each model.members as |m|}} + + - {{/if}} - - {{/each}} + + + + + {{/each}} +
{{i18n 'last_post'}}{{i18n 'last_seen'}}
- {{user-info user=m}} - {{#if m.owner}}{{i18n "groups.owner"}}{{/if}} - - {{bound-date m.last_posted_at}} - - {{bound-date m.last_seen_at}} - - {{#unless m.owner}} - - {{/unless}} + {{i18n 'last_post'}}{{i18n 'last_seen'}}
+ {{#user-info user=m skipName=skipName}} + {{#if m.owner}}{{i18n "groups.owner"}}{{/if}} + {{/user-info}}
+ {{bound-date m.last_posted_at}} + + {{bound-date m.last_seen_at}} + + {{#if isOwner}} + {{#unless m.owner}} + + {{/unless}} + {{/if}} +
{{/load-more}} {{else}} diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index c7be3e1fef..d12de33ef2 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -1,7 +1,6 @@ -
-
-
-
+
-
-
-
-
- -

- {{#if model.flair_url}} - - {{avatar-flair - flairURL=model.flair_url - flairBgColor=model.flair_bg_color - flairColor=model.flair_color - groupName=model.name}} - - {{/if}} - {{groupName}} -

-
- - {{#if canEditGroup}} - - {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} +
+
+ +

+ {{#if model.flair_url}} + + {{avatar-flair + flairURL=model.flair_url + flairBgColor=model.flair_bg_color + flairColor=model.flair_color + groupName=model.name}} {{/if}} -

-
+ {{groupName}} + + + + {{#if canEditGroup}} + + {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} + + {{/if}} +
+ +
{{outlet}} -
-
+
diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss new file mode 100644 index 0000000000..c948ab5da2 --- /dev/null +++ b/app/assets/stylesheets/common/base/group.scss @@ -0,0 +1,101 @@ +.group-header { + font-size: 2.142em; + font-weight: normal; +} + +table.group-members { + width: 100%; + + th, tr { + border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); + } + + th { + text-align: left; + } + + tr { + .user-info { + display: block; + } + + td { + color: dark-light-diff($primary, $secondary, 50%, -50%); + padding: 0.8em 0; + } + } +} + +.group-owner-label { + color: $primary; +} + +.group-header, .group-details { + display: table; + + span { + display: table-cell; + vertical-align: middle; + } + + .avatar-flair { + $size: 40px; + + background-size: $size; + height: $size; + width: $size; + + i { + font-size: $size !important; + } + } +} + +.group-edit-btn { + margin-left: 5px; +} + +.form-horizontal { + label { + font-weight: bold; + } + + input[type="text"] { + width: 80% !important; + margin-bottom: 10px; + } + + .group-flair-inputs { + display: inline-block; + margin-top: 30px; + margin-bottom: 30px; + + .group-flair-left { + float: left; + } + + .group-flair-right { + float: left; + margin-left: 30px; + } + } + + .avatar-flair-preview { + position: relative; + width: 45px; + + .avatar-wrapper { + background-color: #f4f4f4; + } + } +} + +#add-user-to-group { + .ac-wrap { + width: 100% !important; + } + + .add { + margin-top: 10px; + } +} diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss deleted file mode 100644 index 82b79388ac..0000000000 --- a/app/assets/stylesheets/common/base/groups.scss +++ /dev/null @@ -1,62 +0,0 @@ -.groups { - .group-header, .group-details { - display: table; - - span { - display: table-cell; - vertical-align: middle; - } - - .avatar-flair { - $size: 40px; - - background-size: $size; - height: $size; - width: $size; - - i { - font-size: $size !important; - } - } - } - - .group-edit-btn { - margin-left: 5px; - } - - .form-horizontal { - label { - font-weight: bold; - } - - input[type="text"] { - width: 80% !important; - margin-bottom: 10px; - } - - .group-flair-inputs { - display: inline-block; - margin-top: 30px; - margin-bottom: 30px; - - .group-flair-left { - float: left; - } - - .group-flair-right { - float: left; - margin-left: 30px; - } - } - - .avatar-flair-preview { - position: relative; - width: 45px; - - .avatar-wrapper { - background-color: #f4f4f4; - } - } - } -} - diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index 989d6cb286..807b43c726 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -19,6 +19,7 @@ @import "desktop/history"; @import "desktop/queued-posts"; @import "desktop/menu-panel"; +@import "desktop/group"; /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ diff --git a/app/assets/stylesheets/desktop/group.scss b/app/assets/stylesheets/desktop/group.scss new file mode 100644 index 0000000000..e368da25fd --- /dev/null +++ b/app/assets/stylesheets/desktop/group.scss @@ -0,0 +1,12 @@ +.group-outlet { + width: 75%; +} + +.group-nav { + width: 20%; + margin-right: 30px; +} + +.group-details { + margin-bottom: 20px; +} diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 08036e0eb5..5ec7d0ecd8 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -133,50 +133,6 @@ } } - table.group-members { - width: 100%; - p { - max-width: 600px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - th { - padding: 0.5em; - text-align: right; - border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - } - td.avatar { - width: 60px; - position: relative; - .is-owner { - position: absolute; - right: 0; - top: 20px; - color: dark-light-diff($primary, $secondary, 50%, -50%); - } - } - td.remove-user { - text-align: right; - } - td { - padding: 0.5em; - border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - img { - margin-right: 10px; - } - span.text { - float: right; - font-size: 1.2em; - color: dark-light-diff($primary, $secondary, 50%, -50%); - } - } - } - - .user-right.groups { - margin-top: 0; - } - .user-right { width: 900px; margin-top: 20px; diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index 4152083286..ce220071ee 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -22,6 +22,7 @@ @import "mobile/search"; @import "mobile/emoji"; @import "mobile/ring"; +@import "mobile/group"; /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ diff --git a/app/assets/stylesheets/mobile/group.scss b/app/assets/stylesheets/mobile/group.scss new file mode 100644 index 0000000000..209b720b82 --- /dev/null +++ b/app/assets/stylesheets/mobile/group.scss @@ -0,0 +1,23 @@ +.group { + margin-top: 15px; +} + +.group-nav, .group-outlet { + width: 100%; +} + +table.group-members { + th { + text-align: center; + } + + tr { + .user-info { + width: 130px; + } + + td { + padding-left: 0.5em; + } + } +} diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 53c1b15745..045c37fd6f 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -27,7 +27,7 @@ test("Browsing Groups", () => { visit("/groups/discourse/messages"); andThen(() => { - ok($('.action-list li').length === 4, 'it should not show messages tab'); + ok($('.nav-stacked li').length === 4, 'it should not show messages tab'); ok(count('.user-stream .item') > 0, "it lists stream items"); }); }); @@ -39,7 +39,7 @@ test("Admin Browsing Groups", () => { visit("/groups/discourse"); andThen(() => { - ok($('.action-list li').length === 5, 'it should show messages tab if user is admin'); + ok($('.nav-stacked li').length === 5, 'it should show messages tab if user is admin'); }); click('.group-edit-btn'); From 1c42d167ec83424c3d77dda82e7f33afed00f4db Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 14:04:18 +0800 Subject: [PATCH 089/122] Missing spinner icon on group pages. --- .../discourse/templates/components/group-post-stream.js.hbs | 2 ++ app/assets/javascripts/discourse/templates/group-index.hbs | 2 ++ app/assets/javascripts/discourse/templates/group-posts.hbs | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/components/group-post-stream.js.hbs b/app/assets/javascripts/discourse/templates/components/group-post-stream.js.hbs index 9ab4c17c68..a67a93d4e5 100644 --- a/app/assets/javascripts/discourse/templates/components/group-post-stream.js.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-post-stream.js.hbs @@ -7,3 +7,5 @@ {{/each}} {{/load-more}} + +{{conditional-loading-spinner condition=loading}} diff --git a/app/assets/javascripts/discourse/templates/group-index.hbs b/app/assets/javascripts/discourse/templates/group-index.hbs index bbfe6334d2..5e1fd167ee 100644 --- a/app/assets/javascripts/discourse/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/templates/group-index.hbs @@ -41,6 +41,8 @@
{{/load-more}} + + {{conditional-loading-spinner condition=loading}} {{else}}
{{i18n "groups.empty.users"}}
{{/if}} diff --git a/app/assets/javascripts/discourse/templates/group-posts.hbs b/app/assets/javascripts/discourse/templates/group-posts.hbs index f38104880f..7dbeebb300 100644 --- a/app/assets/javascripts/discourse/templates/group-posts.hbs +++ b/app/assets/javascripts/discourse/templates/group-posts.hbs @@ -1 +1 @@ -{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore"}} +{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore" loading=loading}} From 3e099ab2b1e76689a0aadc1797c841bfcd40f146 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 17:37:43 +1100 Subject: [PATCH 090/122] PERF: avoid query on every logged on page load --- app/models/user_option.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 99be107d21..7ef3b5262a 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -87,8 +87,14 @@ class UserOption < ActiveRecord::Base def redirected_to_top # redirect is enabled return unless SiteSetting.redirect_users_to_top_page + + # PERF: bypass min_redirected_to_top query for users that were seen already + return if user.trust_level > 0 && user.last_seen_at && user.last_seen_at > 1.month.ago + # top must be in the top_menu return unless SiteSetting.top_menu =~ /(^|\|)top(\||$)/i + + # not enough topics return unless period = SiteSetting.min_redirected_to_top_period(1.days.ago) From dab48b7c17e9534cffc8e72971d4d1eb3ebe53cd Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 5 Dec 2016 17:40:47 +1100 Subject: [PATCH 091/122] correct regression --- app/serializers/listable_topic_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index bf62e14f68..13a87235c1 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -27,7 +27,7 @@ class ListableTopicSerializer < BasicTopicSerializer has_one :last_poster, serializer: BasicUserSerializer, embed: :objects def highest_post_number - (scope.is_staff? && object.highest_staff_post_number) || object.highest_post_num + (scope.is_staff? && object.highest_staff_post_number) || object.highest_post_number end def liked From ce974da9e55708d904aabdfb140ff273e475fa71 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sun, 4 Dec 2016 21:36:35 +0530 Subject: [PATCH 092/122] FIX: simplify CSV file upload --- .../discourse/components/csv-uploader.js.es6 | 17 ++++++ .../controllers/user-invited-show.js.es6 | 2 - .../discourse/routes/user-invited-show.js.es6 | 8 --- .../templates/components/csv-uploader.hbs | 7 +++ .../discourse/templates/user-invited-show.hbs | 2 +- app/controllers/invites_controller.rb | 57 +++++++------------ app/jobs/regular/bulk_invite.rb | 26 +-------- app/models/invite.rb | 8 ++- config/locales/client.en.yml | 4 +- config/locales/server.en.yml | 3 +- config/routes.rb | 8 +-- config/site_settings.yml | 2 +- spec/controllers/invites_controller_spec.rb | 43 ++------------ spec/jobs/bulk_invite_spec.rb | 11 +--- 14 files changed, 67 insertions(+), 131 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/csv-uploader.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/csv-uploader.hbs diff --git a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 new file mode 100644 index 0000000000..3e6505b190 --- /dev/null +++ b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 @@ -0,0 +1,17 @@ +import computed from "ember-addons/ember-computed-decorators"; +import UploadMixin from "discourse/mixins/upload"; + +export default Em.Component.extend(UploadMixin, { + type: "csv", + tagName: "span", + uploadUrl: "/invites/upload_csv", + + @computed("uploading") + uploadButtonText(uploading) { + return uploading ? I18n.t("uploading") : I18n.t("user.invited.bulk_invite.text"); + }, + + uploadDone() { + bootbox.alert(I18n.t("user.invited.bulk_invite.success")); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 index 15d7134358..78c786847e 100644 --- a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 @@ -18,8 +18,6 @@ export default Ember.Controller.extend({ this.set('searchTerm', ''); }, - uploadText: function() { return I18n.t("user.invited.bulk_invite.text"); }.property(), - /** Observe the search term box with a debouncer and change the results. diff --git a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 index c9465fa7d1..9be0fc4823 100644 --- a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 @@ -33,14 +33,6 @@ export default Discourse.Route.extend({ showInvite() { showModal("invite", { model: this.currentUser }); this.controllerFor("invite").reset(); - }, - - uploadSuccess(filename) { - bootbox.alert(I18n.t("user.invited.bulk_invite.success", { filename: filename })); - }, - - uploadError(filename, message) { - bootbox.alert(I18n.t("user.invited.bulk_invite.error", { filename: filename, message: message })); } } }); diff --git a/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs b/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs new file mode 100644 index 0000000000..eab8d71fc9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs @@ -0,0 +1,7 @@ + +{{#if uploading}} + {{i18n 'upload_selector.uploading'}} {{uploadProgress}}% +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/user-invited-show.hbs b/app/assets/javascripts/discourse/templates/user-invited-show.hbs index 6588a53e01..fc8657b0d4 100644 --- a/app/assets/javascripts/discourse/templates/user-invited-show.hbs +++ b/app/assets/javascripts/discourse/templates/user-invited-show.hbs @@ -16,7 +16,7 @@
{{d-button icon="plus" action="showInvite" label="user.invited.create" class="btn"}} {{#if canBulkInvite}} - {{resumable-upload target="/invites/upload" success="uploadSuccess" error="uploadError" uploadText=uploadText}} + {{csv-uploader uploading=uploading}} {{/if}} {{#if showReinviteAllButton}} {{#if reinvitedAll}} diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 01936f6871..ae62b9b873 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,7 +6,7 @@ class InvitesController < ApplicationController skip_before_filter :check_xhr, :preload_json skip_before_filter :redirect_to_login_if_required - before_filter :ensure_logged_in, only: [:destroy, :create, :create_invite_link, :resend_invite, :resend_all_invites, :check_csv_chunk, :upload_csv_chunk] + before_filter :ensure_logged_in, only: [:destroy, :create, :create_invite_link, :resend_invite, :resend_all_invites, :upload_csv] before_filter :ensure_new_registrations_allowed, only: [:show, :redeem_disposable_invite] before_filter :ensure_not_logged_in, only: [:show, :redeem_disposable_invite] @@ -147,48 +147,29 @@ class InvitesController < ApplicationController render nothing: true end - def check_csv_chunk + def upload_csv guardian.ensure_can_bulk_invite_to_forum!(current_user) - filename = params.fetch(:resumableFilename) - identifier = params.fetch(:resumableIdentifier) - chunk_number = params.fetch(:resumableChunkNumber) - current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i + file = params[:file] || params[:files].first + name = params[:name] || File.basename(file.original_filename, ".*") + extension = File.extname(file.original_filename) - # path to chunk file - chunk = Invite.chunk_path(identifier, filename, chunk_number) - # check chunk upload status - status = HandleChunkUpload.check_chunk(chunk, current_chunk_size: current_chunk_size) - - render nothing: true, status: status - end - - def upload_csv_chunk - guardian.ensure_can_bulk_invite_to_forum!(current_user) - - filename = params.fetch(:resumableFilename) - return render status: 415, text: I18n.t("bulk_invite.file_should_be_csv") unless (filename.to_s.end_with?(".csv") || filename.to_s.end_with?(".txt")) - - file = params.fetch(:file) - identifier = params.fetch(:resumableIdentifier) - chunk_number = params.fetch(:resumableChunkNumber).to_i - chunk_size = params.fetch(:resumableChunkSize).to_i - total_size = params.fetch(:resumableTotalSize).to_i - current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i - - # path to chunk file - chunk = Invite.chunk_path(identifier, filename, chunk_number) - # upload chunk - HandleChunkUpload.upload_chunk(chunk, file: file) - - uploaded_file_size = chunk_number * chunk_size - # when all chunks are uploaded - if uploaded_file_size + current_chunk_size >= total_size - # handle bulk_invite processing in a background thread - Jobs.enqueue(:bulk_invite, filename: filename, identifier: identifier, chunks: chunk_number, current_user_id: current_user.id) + Scheduler::Defer.later("Upload CSV") do + begin + data = if extension == ".csv" + path = Invite.create_csv(file, name) + Jobs.enqueue(:bulk_invite, filename: "#{name}.csv", current_user_id: current_user.id) + {url: path} + else + failed_json.merge(errors: [I18n.t("bulk_invite.file_should_be_csv")]) + end + rescue + failed_json.merge(errors: [I18n.t("bulk_invite.error")]) + end + MessageBus.publish("/uploads/csv", data.as_json, user_ids: [current_user.id]) end - render nothing: true + render json: success_json end def fetch_username diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb index 44b14abe64..6fb1eeb863 100644 --- a/app/jobs/regular/bulk_invite.rb +++ b/app/jobs/regular/bulk_invite.rb @@ -14,21 +14,12 @@ module Jobs end def execute(args) - filename = args[:filename] - identifier = args[:identifier] - chunks = args[:chunks].to_i + filename = args[:filename] @current_user = User.find_by(id: args[:current_user_id]) - - raise Discourse::InvalidParameters.new(:filename) if filename.blank? - raise Discourse::InvalidParameters.new(:identifier) if identifier.blank? - raise Discourse::InvalidParameters.new(:chunks) if chunks <= 0 - - # merge chunks, and get csv path - csv_path = get_csv_path(filename, identifier, chunks) + raise Discourse::InvalidParameters.new(:filename) if filename.blank? # read csv file, and send out invitations - read_csv_file(csv_path) - + read_csv_file("#{Invite.base_directory}/#{filename}") ensure # send notification to user regarding progress notify_user @@ -37,17 +28,6 @@ module Jobs FileUtils.rm_rf(csv_path) rescue nil end - def get_csv_path(filename, identifier, chunks) - csv_path = "#{Invite.base_directory}/#{filename}" - tmp_csv_path = "#{csv_path}.tmp" - # path to tmp directory - tmp_directory = File.dirname(Invite.chunk_path(identifier, filename, 0)) - # merge all chunks - HandleChunkUpload.merge_chunks(chunks, upload_path: csv_path, tmp_upload_path: tmp_csv_path, model: Invite, identifier: identifier, filename: filename, tmp_directory: tmp_directory) - - return csv_path - end - def read_csv_file(csv_path) CSV.foreach(csv_path, encoding: "iso-8859-1:UTF-8") do |csv_info| if csv_info[0] diff --git a/app/models/invite.rb b/app/models/invite.rb index 989631d2bd..8ecef5eda0 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -265,8 +265,12 @@ class Invite < ActiveRecord::Base File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db) end - def self.chunk_path(identifier, filename, chunk_number) - File.join(Invite.base_directory, "tmp", identifier, "#{filename}.part#{chunk_number}") + def self.create_csv(file, name) + extension = File.extname(file.original_filename) + path = "#{Invite.base_directory}/#{name}#{extension}" + FileUtils.mkdir_p(Pathname.new(path).dirname) + File.open(path, "wb") { |f| f << file.tempfile.read } + path end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e65c6f928c..98074099f4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -778,11 +778,9 @@ en: link_generated: "Invite link generated successfully!" valid_for: "Invite link is only valid for this email address: %{email}" bulk_invite: - none: "You haven't invited anyone here yet. You can send individual invites, or invite a bunch of people at once by uploading a bulk invite file." + none: "You haven't invited anyone here yet. You can send individual invites, or invite a bunch of people at once by uploading a CSV file." text: "Bulk Invite from File" - uploading: "Uploading..." success: "File uploaded successfully, you will be notified via message when the process is complete." - error: "There was an error uploading '{{filename}}': {{message}}" password: title: "Password" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 901c636852..a9048c3a73 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -134,7 +134,8 @@ en: <<: *errors bulk_invite: - file_should_be_csv: "The uploaded file should be of csv or txt format." + file_should_be_csv: "The uploaded file should be of csv format." + error: "There was an error uploading that file. Please try again later." backup: operation_already_running: "An operation is currently running. Can't start a new job right now." diff --git a/config/routes.rb b/config/routes.rb index 770d819945..5bffa05f74 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -617,12 +617,8 @@ Discourse::Application.routes.draw do resources :queued_posts, constraints: StaffConstraint.new get 'queued-posts' => 'queued_posts#index' - resources :invites do - collection do - get "upload" => "invites#check_csv_chunk" - post "upload" => "invites#upload_csv_chunk" - end - end + resources :invites + post "invites/upload_csv" => "invites#upload_csv" post "invites/reinvite" => "invites#resend_invite" post "invites/reinvite-all" => "invites#resend_all_invites" post "invites/link" => "invites#create_invite_link" diff --git a/config/site_settings.yml b/config/site_settings.yml index 76b2959666..5899f29c66 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -686,7 +686,7 @@ files: default: 3072 authorized_extensions: client: true - default: 'jpg|jpeg|png|gif' + default: 'jpg|jpeg|png|gif|csv' refresh: true type: list crawl_images: diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 745c781f9c..be07bb7894 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -367,33 +367,10 @@ describe InvitesController do end - context '.check_csv_chunk' do + context '.upload_csv' do it 'requires you to be logged in' do expect { - post :check_csv_chunk - }.to raise_error(Discourse::NotLoggedIn) - end - - context 'while logged in' do - let(:resumableChunkNumber) { 1 } - let(:resumableCurrentChunkSize) { 46 } - let(:resumableIdentifier) { '46-discoursecsv' } - let(:resumableFilename) { 'discourse.csv' } - - it "fails if you can't bulk invite to the forum" do - log_in - post :check_csv_chunk, resumableChunkNumber: resumableChunkNumber, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename - expect(response).not_to be_success - end - - end - - end - - context '.upload_csv_chunk' do - it 'requires you to be logged in' do - expect { - post :upload_csv_chunk + xhr :post, :upload_csv }.to raise_error(Discourse::NotLoggedIn) end @@ -402,27 +379,19 @@ describe InvitesController do let(:file) do ActionDispatch::Http::UploadedFile.new({ filename: 'discourse.csv', tempfile: csv_file }) end - let(:resumableChunkNumber) { 1 } - let(:resumableChunkSize) { 1048576 } - let(:resumableCurrentChunkSize) { 46 } - let(:resumableTotalSize) { 46 } - let(:resumableType) { 'text/csv' } - let(:resumableIdentifier) { '46-discoursecsv' } - let(:resumableFilename) { 'discourse.csv' } - let(:resumableRelativePath) { 'discourse.csv' } + let(:filename) { 'discourse.csv' } it "fails if you can't bulk invite to the forum" do log_in - post :upload_csv_chunk, file: file, resumableChunkNumber: resumableChunkNumber.to_i, resumableChunkSize: resumableChunkSize.to_i, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableTotalSize: resumableTotalSize.to_i, resumableType: resumableType, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename + xhr :post, :upload_csv, file: file, name: filename expect(response).not_to be_success end - it "allows admins to bulk invite" do + it "allows admin to bulk invite" do log_in(:admin) - post :upload_csv_chunk, file: file, resumableChunkNumber: resumableChunkNumber.to_i, resumableChunkSize: resumableChunkSize.to_i, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableTotalSize: resumableTotalSize.to_i, resumableType: resumableType, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename + xhr :post, :upload_csv, file: file, name: filename expect(response).to be_success end - end end diff --git a/spec/jobs/bulk_invite_spec.rb b/spec/jobs/bulk_invite_spec.rb index 77629a6a82..0739a41574 100644 --- a/spec/jobs/bulk_invite_spec.rb +++ b/spec/jobs/bulk_invite_spec.rb @@ -5,15 +5,8 @@ describe Jobs::BulkInvite do context '.execute' do it 'raises an error when the filename is missing' do - expect { Jobs::BulkInvite.new.execute(identifier: '46-discoursecsv', chunks: '1') }.to raise_error(Discourse::InvalidParameters) - end - - it 'raises an error when the identifier is missing' do - expect { Jobs::BulkInvite.new.execute(filename: 'discourse.csv', chunks: '1') }.to raise_error(Discourse::InvalidParameters) - end - - it 'raises an error when the chunks is missing' do - expect { Jobs::BulkInvite.new.execute(filename: 'discourse.csv', identifier: '46-discoursecsv') }.to raise_error(Discourse::InvalidParameters) + user = Fabricate(:user) + expect { Jobs::BulkInvite.new.execute(current_user_id: user.id) }.to raise_error(Discourse::InvalidParameters) end context '.read_csv_file' do From adb7fcb6b3ec5d00f5ed1dd0fb215faaaeec9a18 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 16:18:24 +0800 Subject: [PATCH 093/122] FEATURE: Add bio to group page. --- .../javascripts/admin/templates/group.hbs | 5 +++ .../javascripts/discourse/models/group.js.es6 | 1 + .../javascripts/discourse/templates/group.hbs | 44 +++++++++++-------- .../discourse/templates/modal/edit-group.hbs | 3 ++ app/assets/stylesheets/common/base/group.scss | 11 ++++- app/controllers/groups_controller.rb | 7 ++- app/models/group.rb | 7 +++ app/serializers/basic_group_serializer.rb | 20 ++++++++- config/locales/client.en.yml | 1 + .../20161205065743_add_bio_to_groups.rb | 6 +++ spec/integration/groups_spec.rb | 4 +- spec/models/group_spec.rb | 8 +++- .../javascripts/acceptance/groups-test.js.es6 | 1 + 13 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 db/migrate/20161205065743_add_bio_to_groups.rb diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index b8852002aa..42993a1e92 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -11,6 +11,11 @@ {{#if model.id}} {{#unless model.automatic}} +
+ + {{d-editor value=model.bio_raw}} +
+ {{#if model.hasOwners}}
diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 68550d2c2b..510149b6ba 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -114,6 +114,7 @@ const Group = Discourse.Model.extend({ flair_url: this.get('flair_url'), flair_bg_color: this.get('flairBackgroundHexColor'), flair_color: this.get('flairHexColor'), + bio_raw: this.get('bio_raw') }; }, diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index d12de33ef2..1fa500cc97 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -13,26 +13,34 @@
-
- -

- {{#if model.flair_url}} - - {{avatar-flair - flairURL=model.flair_url - flairBgColor=model.flair_bg_color - flairColor=model.flair_color - groupName=model.name}} - - {{/if}} - {{groupName}} -

-
- - {{#if canEditGroup}} +
+
- {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} +

+ {{#if model.flair_url}} + + {{avatar-flair + flairURL=model.flair_url + flairBgColor=model.flair_bg_color + flairColor=model.flair_color + groupName=model.name}} + + {{/if}} + {{groupName}} +

+ + {{#if canEditGroup}} + + {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} + + {{/if}} +
+ + {{#if model.bio_cooked}} +
+

{{{model.bio_cooked}}}

+
{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/modal/edit-group.hbs b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs index f467e5e94e..0a3ffabd92 100644 --- a/app/assets/javascripts/discourse/templates/modal/edit-group.hbs +++ b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs @@ -1,5 +1,8 @@ {{#d-modal-body title="group.edit.title" class="edit-group groups"}}
+ + {{d-editor value=model.bio_raw class="edit-group-bio"}} + {{group-flair-inputs model=model}}
{{/d-modal-body}} diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index c948ab5da2..5b360ede9c 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -3,6 +3,11 @@ font-weight: normal; } +.group-details-container { + background: rgba(204, 204, 204, 0.2); + padding: 20px; +} + table.group-members { width: 100%; @@ -55,7 +60,11 @@ table.group-members { margin-left: 5px; } -.form-horizontal { +.groups.edit-group .form-horizontal { + textarea { + width: 99%; + } + label { font-weight: bold; } diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 918a113848..af7e39e4cc 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -169,7 +169,12 @@ class GroupsController < ApplicationController private def group_params - params.require(:group).permit(:flair_url, :flair_bg_color, :flair_color) + params.require(:group).permit( + :flair_url, + :flair_bg_color, + :flair_color, + :bio_raw + ) end def find_group(param_name) diff --git a/app/models/group.rb b/app/models/group.rb index c0c487f4d2..dde84c2922 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -13,6 +13,7 @@ class Group < ActiveRecord::Base has_and_belongs_to_many :web_hooks before_save :downcase_incoming_email + before_save :cook_bio after_save :destroy_deletions after_save :automatic_group_membership @@ -83,6 +84,12 @@ class Group < ActiveRecord::Base self.incoming_email = (incoming_email || "").strip.downcase.presence end + def cook_bio + if !self.bio_raw.blank? + self.bio_cooked = PrettyText.cook(self.bio_raw) + end + end + def incoming_email_validator return if self.automatic || self.incoming_email.blank? diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index 783fdafb94..401a88b2ff 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -14,9 +14,25 @@ class BasicGroupSerializer < ApplicationSerializer :has_messages, :flair_url, :flair_bg_color, - :flair_color + :flair_color, + :bio_raw, + :bio_cooked def include_incoming_email? - scope.is_staff? + staff? + end + + def include_has_messsages + staff? + end + + def include_bio_raw + staff? + end + + private + + def staff? + @staff ||= scope.is_staff? end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 486748c215..bde096aa7a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1849,6 +1849,7 @@ en: edit: title: 'Edit Group' name: "Name" + bio: "About Group" name_placeholder: "Group name, no spaces, same as username rule" flair_url: "Avatar Flair Image" flair_url_placeholder: "(Optional) Image URL or Font Awesome class" diff --git a/db/migrate/20161205065743_add_bio_to_groups.rb b/db/migrate/20161205065743_add_bio_to_groups.rb new file mode 100644 index 0000000000..846b3a1c97 --- /dev/null +++ b/db/migrate/20161205065743_add_bio_to_groups.rb @@ -0,0 +1,6 @@ +class AddBioToGroups < ActiveRecord::Migration + def change + add_column :groups, :bio_raw, :text + add_column :groups, :bio_cooked, :text + end +end diff --git a/spec/integration/groups_spec.rb b/spec/integration/groups_spec.rb index e7f4e9eb7d..4196216c19 100644 --- a/spec/integration/groups_spec.rb +++ b/spec/integration/groups_spec.rb @@ -46,7 +46,8 @@ describe "Groups" do xhr :put, "/groups/#{group.id}", { group: { flair_bg_color: 'FFF', flair_color: 'BBB', - flair_url: 'fa-adjust' + flair_url: 'fa-adjust', + bio_raw: 'testing' } } expect(response).to be_success @@ -56,6 +57,7 @@ describe "Groups" do expect(group.flair_bg_color).to eq('FFF') expect(group.flair_color).to eq('BBB') expect(group.flair_url).to eq('fa-adjust') + expect(group.bio_raw).to eq('testing') end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index dfc54ba1a8..8a6477ed73 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -322,7 +322,6 @@ describe Group do expect(Group.desired_trust_level_groups(2).sort).to eq [10,11,12] end - it "correctly handles trust level changes" do user = Fabricate(:user, trust_level: 2) Group.user_trust_level_change!(user.id, 2) @@ -369,4 +368,11 @@ describe Group do expect(u3.reload.trust_level).to eq(3) end + it 'should cook the bio' do + group = Fabricate(:group) + group.update_attributes!(bio_raw: 'This is a group for :unicorn: lovers') + + expect(group.bio_cooked).to include("unicorn.png") + end + end diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 045c37fd6f..8cb827f3ef 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -46,5 +46,6 @@ test("Admin Browsing Groups", () => { andThen(() => { ok(find('.group-flair-inputs').length === 1, 'it should display avatar flair inputs'); + ok(find('.edit-group-bio').length === 1, 'it should display group bio input'); }); }); From 248c5af55642b2fbb31299fa3d00f9fe954c8b2e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 16:53:58 +0800 Subject: [PATCH 094/122] UX: Restyle group pages a little. --- .../javascripts/discourse/templates/group.hbs | 62 +++++++++---------- app/assets/stylesheets/common/base/group.scss | 10 ++- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 1fa500cc97..a8b8771230 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -1,4 +1,35 @@
+
+
+ +

+ {{#if model.flair_url}} + + {{avatar-flair + flairURL=model.flair_url + flairBgColor=model.flair_bg_color + flairColor=model.flair_color + groupName=model.name}} + + {{/if}} + {{groupName}} +

+
+ + {{#if canEditGroup}} + + {{d-button action="showGroupEditor" label="group.edit.title" class="group-edit-btn" icon="pencil"}} + + {{/if}} +
+ + {{#if model.bio_cooked}} +
+

{{{model.bio_cooked}}}

+
+ {{/if}} +
+
-
-
- -

- {{#if model.flair_url}} - - {{avatar-flair - flairURL=model.flair_url - flairBgColor=model.flair_bg_color - flairColor=model.flair_color - groupName=model.name}} - - {{/if}} - {{groupName}} -

-
- - {{#if canEditGroup}} - - {{d-button action="showGroupEditor" class="group-edit-btn btn-small" icon="pencil"}} - - {{/if}} -
- - {{#if model.bio_cooked}} -
-

{{{model.bio_cooked}}}

-
- {{/if}} -
-
{{outlet}}
diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index 5b360ede9c..b9a0e2878f 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -4,8 +4,9 @@ } .group-details-container { - background: rgba(204, 204, 204, 0.2); + background: rgba(230, 230, 230, 0.3); padding: 20px; + margin-bottom: 30px; } table.group-members { @@ -35,6 +36,10 @@ table.group-members { color: $primary; } +.group-details { + width: 100%; +} + .group-header, .group-details { display: table; @@ -58,6 +63,7 @@ table.group-members { .group-edit-btn { margin-left: 5px; + float: right; } .groups.edit-group .form-horizontal { @@ -100,6 +106,8 @@ table.group-members { } #add-user-to-group { + margin: 0px; + .ac-wrap { width: 100% !important; } From 37b256e7f280d4000a5ca68457a620c16c8dcc0c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 17:13:47 +0800 Subject: [PATCH 095/122] Fix specs. --- spec/controllers/admin/groups_controller_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 39fc436e3a..53cb83436a 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -37,7 +37,9 @@ describe Admin::GroupsController do "has_messages"=>false, "flair_url"=>nil, "flair_bg_color"=>nil, - "flair_color"=>nil + "flair_color"=>nil, + "bio_raw"=>nil, + "bio_cooked"=>nil }]) end From 965b38ff2a63fa3c7e2d174be2cbd01398839454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 11:08:30 +0100 Subject: [PATCH 096/122] FIX: safari would lose selection sometimes --- .../javascripts/discourse/components/quote-button.js.es6 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index b6012ed455..7fff911e5e 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -38,6 +38,9 @@ export default Ember.Component.extend({ } } + // used to work around Safari losing selection + const clone = firstRange.cloneRange(); + this.get("quoteState").setProperties({ postId, buffer: selectedText() }); // on Desktop, shows the button at the beginning of the selection @@ -64,6 +67,11 @@ export default Ember.Component.extend({ // remove the marker markerElement.parentNode.removeChild(markerElement); + // work around Safari that would sometimes lose the selection + const s = window.getSelection(); + s.removeAllRanges(); + s.addRange(clone); + // change the position of the button Ember.run.scheduleOnce("afterRender", () => { let top = markerOffset.top; From cdb7e14fa7e1fec62d68674e30eaad99fb13a868 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 18:12:24 +0800 Subject: [PATCH 097/122] PERF: Show excerpt on group page. --- .../templates/components/group-post.hbs | 6 ++--- app/serializers/basic_user_serializer.rb | 2 +- app/serializers/group_post_serializer.rb | 26 +++---------------- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/components/group-post.hbs b/app/assets/javascripts/discourse/templates/components/group-post.hbs index 45a1642281..09ac259ce2 100644 --- a/app/assets/javascripts/discourse/templates/components/group-post.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-post.hbs @@ -7,12 +7,12 @@ {{category-link post.category}}
- {{#if post.user_long_name}} - {{post.user_long_name}}{{#if post.user_title}}, {{post.user_title}}{{/if}} + {{#if post.user.name}} + {{post.user.name}}{{#if post.user.title}}, {{post.user.title}}{{/if}} {{/if}}

- {{{unbound post.cooked}}} + {{{unbound post.excerpt}}}

diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb index 8880c8dbd7..6c46413dcd 100644 --- a/app/serializers/basic_user_serializer.rb +++ b/app/serializers/basic_user_serializer.rb @@ -1,5 +1,5 @@ class BasicUserSerializer < ApplicationSerializer - attributes :id, :username, :avatar_template + attributes :id, :username, :avatar_template, :title, :name def include_name? SiteSetting.enable_names? diff --git a/app/serializers/group_post_serializer.rb b/app/serializers/group_post_serializer.rb index 77f1eb95b3..b6f3671613 100644 --- a/app/serializers/group_post_serializer.rb +++ b/app/serializers/group_post_serializer.rb @@ -1,42 +1,22 @@ class GroupPostSerializer < ApplicationSerializer attributes :id, - :cooked, + :excerpt, :created_at, :title, :url, - :user_title, - :user_long_name, - :topic, :category - has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :user, serializer: BasicUserSerializer, embed: :object + has_one :topic, serializer: BasicTopicSerializer, embed: :object def title object.topic.title end - def user_long_name - object.user.try(:name) - end - - def user_title - object.user.try(:title) - end - def include_user_long_name? SiteSetting.enable_names? end - def topic - object.topic - end - - def cooked - fragment = Nokogiri::HTML.fragment(object.cooked) - DiscourseEvent.trigger(:reduce_cooked, fragment, object) - fragment.to_html - end - def category object.topic.category end From edce052660392e22732f9e4374362c0bc87123cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 11:32:38 +0100 Subject: [PATCH 098/122] FIX: isSafari detection wasn't working with latest Safari --- .../discourse/pre-initializers/sniff-capabilities.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 index 9a409fcc82..593cdccf68 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -20,7 +20,7 @@ export default { caps.isOpera = !!window.opera || ua.indexOf(' OPR/') >= 0; caps.isFirefox = typeof InstallTrigger !== 'undefined'; - caps.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; + caps.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0 || (function (p) { return p.toString() === "[object SafariRemoteNotification]"; })(!window['safari'] || safari.pushNotification); caps.isChrome = !!window.chrome && !caps.isOpera; caps.canPasteImages = caps.isChrome || caps.isFirefox; } From 9f3eaf4c955b076b6819338c342639c4ac12cccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 11:33:26 +0100 Subject: [PATCH 099/122] FIX: prevent selectionchanged trigger loop on Safari --- .../discourse/components/quote-button.js.es6 | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index 7fff911e5e..77a5c8192a 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -15,6 +15,7 @@ export default Ember.Component.extend({ visible: buffer => buffer && buffer.length > 0, _isMouseDown: false, + _reselected: false, _selectionChanged() { const selection = window.getSelection(); @@ -38,17 +39,17 @@ export default Ember.Component.extend({ } } - // used to work around Safari losing selection - const clone = firstRange.cloneRange(); - this.get("quoteState").setProperties({ postId, buffer: selectedText() }); // on Desktop, shows the button at the beginning of the selection // on Mobile, shows the button at the end of the selection const isMobileDevice = this.site.isMobileDevice; - const { isIOS, isAndroid } = this.capabilities; + const { isIOS, isAndroid, isSafari } = this.capabilities; const showAtEnd = isMobileDevice || isIOS || isAndroid; + // used to work around Safari losing selection + const clone = firstRange.cloneRange(); + // create a marker element containing a single invisible character const markerElement = document.createElement("span"); markerElement.appendChild(document.createTextNode("\ufeff")); @@ -68,9 +69,11 @@ export default Ember.Component.extend({ markerElement.parentNode.removeChild(markerElement); // work around Safari that would sometimes lose the selection - const s = window.getSelection(); - s.removeAllRanges(); - s.addRange(clone); + if (isSafari) { + this._reselected = true; + selection.removeAllRanges(); + selection.addRange(clone); + } // change the position of the button Ember.run.scheduleOnce("afterRender", () => { @@ -96,6 +99,7 @@ export default Ember.Component.extend({ $(document).on("mousedown.quote-button", (e) => { this._isMouseDown = true; + this._reselected = false; if (!willQuote(e)) { this.sendAction("deselectText"); } @@ -103,7 +107,7 @@ export default Ember.Component.extend({ this._isMouseDown = false; onSelectionChanged(); }).on("selectionchange.quote-button", () => { - if (!this._isMouseDown) { + if (!this._isMouseDown && !this._reselected) { onSelectionChanged(); } }); From bea0856f1cf93c11ef0c04db4f2811ad6fa94860 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 5 Dec 2016 18:46:23 +0800 Subject: [PATCH 100/122] FIX: Move title and name out of BasicUserSerializer. --- app/serializers/basic_user_serializer.rb | 2 +- app/serializers/group_post_serializer.rb | 2 +- app/serializers/group_post_user_serializer.rb | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 app/serializers/group_post_user_serializer.rb diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb index 6c46413dcd..8880c8dbd7 100644 --- a/app/serializers/basic_user_serializer.rb +++ b/app/serializers/basic_user_serializer.rb @@ -1,5 +1,5 @@ class BasicUserSerializer < ApplicationSerializer - attributes :id, :username, :avatar_template, :title, :name + attributes :id, :username, :avatar_template def include_name? SiteSetting.enable_names? diff --git a/app/serializers/group_post_serializer.rb b/app/serializers/group_post_serializer.rb index b6f3671613..1fb6305e59 100644 --- a/app/serializers/group_post_serializer.rb +++ b/app/serializers/group_post_serializer.rb @@ -6,7 +6,7 @@ class GroupPostSerializer < ApplicationSerializer :url, :category - has_one :user, serializer: BasicUserSerializer, embed: :object + has_one :user, serializer: GroupPostUserSerializer, embed: :object has_one :topic, serializer: BasicTopicSerializer, embed: :object def title diff --git a/app/serializers/group_post_user_serializer.rb b/app/serializers/group_post_user_serializer.rb new file mode 100644 index 0000000000..5a69f23fbc --- /dev/null +++ b/app/serializers/group_post_user_serializer.rb @@ -0,0 +1,3 @@ +class GroupPostUserSerializer < BasicUserSerializer + attributes :title, :name +end From 951ef0d949dd98ba768ed2baad2d1895b3c3776b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 12:00:04 +0100 Subject: [PATCH 101/122] UX: fix onebox styling in emails --- lib/email/styles.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/email/styles.rb b/lib/email/styles.rb index cb20f3dca1..db32a221d7 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -114,12 +114,12 @@ module Email style('blockquote > p', 'padding: 1em;') # Oneboxes - style('aside.onebox', "padding: 12px 25px 2px 12px; border-left: 5px solid #bebebe; background: #eee; margin-bottom: 10px;") - style('aside.onebox img', "max-height: 80%; max-width: 25%; height: auto; float: left; margin-right: 10px; margin-bottom: 10px") - style('aside.onebox h3', "border-bottom: 0") - style('aside.onebox .source', "margin-bottom: 8px") - style('aside.onebox .source a[href]', "color: #333; font-weight: normal") - style('aside.clearfix', "clear: both") + style('aside.onebox', "border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px;") + style('aside.onebox header a[href]', "color: #222222; text-decoration: none;") + style('aside.onebox .onebox-body', "clear: both") + style('aside.onebox .onebox-body img', "max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;") + style('aside.onebox .onebox-body h3, aside.onebox .onebox-body h4', "font-size: 1.17em; margin: 10px 0;") + style('.onebox-metadata', "color: #919191") # Finally, convert all `aside` tags to `div`s @fragment.css('aside, article, header').each do |n| From 59523aef9da00fe19ceabaa6cb2bece956f86c06 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Mon, 5 Dec 2016 17:41:59 +0530 Subject: [PATCH 102/122] more improvements to vBulletin import script --- script/import_scripts/vbulletin.rb | 115 +++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index fb1deb292f..8ff7c0340a 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -31,6 +31,7 @@ class ImportScripts::VBulletin < ImportScripts::Base def execute import_groups import_users + create_groups_membership import_categories import_topics import_posts @@ -40,7 +41,7 @@ class ImportScripts::VBulletin < ImportScripts::Base close_topics post_process_posts - create_permalinks + create_permalink_file suspend_users end @@ -89,7 +90,7 @@ class ImportScripts::VBulletin < ImportScripts::Base email: user["email"].presence || fake_email, website: user["homepage"].strip, title: @htmlentities.decode(user["usertitle"]).strip, - primary_group_id: group_id_from_imported_group_id(user["usergroupid"]), + primary_group_id: group_id_from_imported_group_id(user["usergroupid"].to_i), created_at: parse_timestamp(user["joindate"]), last_seen_at: parse_timestamp(user["lastvisit"]), post_create_action: proc do |u| @@ -102,6 +103,32 @@ class ImportScripts::VBulletin < ImportScripts::Base end end + def create_groups_membership + puts "", "Creating groups membership..." + + Group.find_each do |group| + begin + next if group.automatic + puts "\t#{group.name}" + next if GroupUser.where(group_id: group.id).count > 0 + user_ids_in_group = User.where(primary_group_id: group.id).pluck(:id).to_a + next if user_ids_in_group.size == 0 + values = user_ids_in_group.map { |user_id| "(#{group.id}, #{user_id}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" }.join(",") + + User.exec_sql <<-SQL + BEGIN; + INSERT INTO group_users (group_id, user_id, created_at, updated_at) VALUES #{values}; + COMMIT; + SQL + + Group.reset_counters(group.id, :group_users) + rescue Exception => e + puts e.message + puts e.backtrace.join("\n") + end + end + end + def import_profile_picture(old_user, imported_user) query = mysql_query <<-SQL SELECT filedata, filename @@ -163,9 +190,9 @@ class ImportScripts::VBulletin < ImportScripts::Base categories = mysql_query("SELECT forumid, title, description, displayorder, parentid FROM #{TABLE_PREFIX}forum ORDER BY forumid").to_a - # top_level_categories = categories.select { |c| c["parentid"] == -1 } + top_level_categories = categories.select { |c| c["parentid"] == -1 } - create_categories(categories) do |category| + create_categories(top_level_categories) do |category| { id: category["forumid"], name: @htmlentities.decode(category["title"]).strip, @@ -174,27 +201,27 @@ class ImportScripts::VBulletin < ImportScripts::Base } end - # puts "", "importing children categories..." - # - # children_categories = categories.select { |c| c["parentid"] != -1 } - # top_level_category_ids = Set.new(top_level_categories.map { |c| c["forumid"] }) - # - # # cut down the tree to only 2 levels of categories - # children_categories.each do |cc| - # while !top_level_category_ids.include?(cc["parentid"]) - # cc["parentid"] = categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"] - # end - # end - # - # create_categories(children_categories) do |category| - # { - # id: category["forumid"], - # name: @htmlentities.decode(category["title"]).strip, - # position: category["displayorder"], - # description: @htmlentities.decode(category["description"]).strip, - # parent_category_id: category_id_from_imported_category_id(category["parentid"]) - # } - # end + puts "", "importing children categories..." + + children_categories = categories.select { |c| c["parentid"] != -1 } + top_level_category_ids = Set.new(top_level_categories.map { |c| c["forumid"] }) + + # cut down the tree to only 2 levels of categories + children_categories.each do |cc| + while !top_level_category_ids.include?(cc["parentid"]) + cc["parentid"] = categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"] + end + end + + create_categories(children_categories) do |category| + { + id: category["forumid"], + name: @htmlentities.decode(category["title"]).strip, + position: category["displayorder"], + description: @htmlentities.decode(category["description"]).strip, + parent_category_id: category_id_from_imported_category_id(category["parentid"]) + } + end end def import_topics @@ -237,6 +264,18 @@ class ImportScripts::VBulletin < ImportScripts::Base t[:pinned_at] = t[:created_at] if topic["sticky"].to_i == 1 t end + + # uncomment below lines to create permalink + # topics.each do |thread| + # topic_id = "thread-#{thread["threadid"]}" + # topic = topic_lookup_from_imported_post_id(topic_id) + # if topic.present? + # title_slugified = thread["title"].gsub(" ","-").gsub(".","-") if thread["title"].present? + # url_slug = "threads/#{thread["threadid"]}-#{title_slugified}" if thread["title"].present? + # Permalink.create(url: url_slug, topic_id: topic[:topic_id].to_i) if url_slug.present? && topic[:topic_id].present? + # end + # end + end end @@ -567,6 +606,24 @@ class ImportScripts::VBulletin < ImportScripts::Base "@#{old_username}" end + # [FONT=blah] and [COLOR=blah] + raw.gsub! /\[FONT=.*?\](.*?)\[\/FONT\]/im, '\1' + raw.gsub! /\[COLOR=.*?\](.*?)\[\/COLOR\]/im, '\1' + raw.gsub! /\[COLOR=#.*?\](.*?)\[\/COLOR\]/im, '\1' + + raw.gsub! /\[SIZE=.*?\](.*?)\[\/SIZE\]/im, '\1' + raw.gsub! /\[h=.*?\](.*?)\[\/h\]/im, '\1' + + # [CENTER]...[/CENTER] + raw.gsub! /\[CENTER\](.*?)\[\/CENTER\]/im, '\1' + + # [INDENT]...[/INDENT] + raw.gsub! /\[INDENT\](.*?)\[\/INDENT\]/im, '\1' + raw.gsub! /\[TABLE\](.*?)\[\/TABLE\]/im, '\1' + raw.gsub! /\[TR\](.*?)\[\/TR\]/im, '\1' + raw.gsub! /\[TD\](.*?)\[\/TD\]/im, '\1' + raw.gsub! /\[TD="?.*?"?\](.*?)\[\/TD\]/im, '\1' + # [QUOTE]...[/QUOTE] raw.gsub!(/\[quote\](.+?)\[\/quote\]/im) { |quote| quote.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n#{$1}\n" } @@ -607,7 +664,7 @@ class ImportScripts::VBulletin < ImportScripts::Base raw.gsub!(/\[\*\]\n/, '') raw.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]') raw.gsub!(/\[\*\](.*?)\n/, '[li]\1[/li]') - + raw.gsub!(/\[\*=1\]/, '') raw end @@ -683,8 +740,8 @@ class ImportScripts::VBulletin < ImportScripts::Base end - def create_permalinks - puts '', 'Creating Permalinks...', '' + def create_permalink_file + puts '', 'Creating Permalink File...', '' id_mapping = [] @@ -723,7 +780,7 @@ class ImportScripts::VBulletin < ImportScripts::Base system_user = Discourse.system_user mysql_query("SELECT userid, bandate FROM #{TABLE_PREFIX}userban").each do |b| - user = User.find_by_id(b['userid']) + user = User.find_by_id(user_id_from_imported_user_id(b['userid'])) if user user.suspended_at = parse_timestamp(user["bandate"]) user.suspended_till = 200.years.from_now From 06469ef0ce37bf5704901322426285692c9715b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 15:19:15 +0100 Subject: [PATCH 103/122] FIX: don't extract links from .elided parts --- app/models/post_analyzer.rb | 48 ++++++++++++++----------------- spec/models/post_analyzer_spec.rb | 11 +++++-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/models/post_analyzer.rb b/app/models/post_analyzer.rb index f5ff4117b1..09f7cd9932 100644 --- a/app/models/post_analyzer.rb +++ b/app/models/post_analyzer.rb @@ -19,7 +19,7 @@ class PostAnalyzer result = Oneboxer.apply(cooked, topic_id: @topic_id) do |url, _| @found_oneboxes = true Oneboxer.invalidate(url) if args.last[:invalidate_oneboxes] - Oneboxer.cached_onebox url + Oneboxer.cached_onebox(url) end cooked = result.to_html if result.changed? @@ -30,10 +30,9 @@ class PostAnalyzer def image_count return 0 unless @raw.present? - cooked_document.search("img").reject do |t| - dom_class = t["class"] - if dom_class - (Post.white_listed_image_classes & dom_class.split(" ")).count > 0 + cooked_stripped.css("img").reject do |t| + if dom_class = t["class"] + (Post.white_listed_image_classes & dom_class.split).count > 0 end end.count end @@ -42,8 +41,8 @@ class PostAnalyzer def attachment_count return 0 unless @raw.present? - attachments = cooked_document.css("a.attachment[href^=\"#{Discourse.store.absolute_base_url}\"]") - attachments += cooked_document.css("a.attachment[href^=\"#{Discourse.store.relative_base_url}\"]") if Discourse.store.internal? + attachments = cooked_stripped.css("a.attachment[href^=\"#{Discourse.store.absolute_base_url}\"]") + attachments += cooked_stripped.css("a.attachment[href^=\"#{Discourse.store.relative_base_url}\"]") if Discourse.store.internal? attachments.count end @@ -51,13 +50,6 @@ class PostAnalyzer return [] if @raw.blank? return @raw_mentions if @raw_mentions.present? - # strip quotes, code blocks and oneboxes - cooked_stripped = cooked_document - cooked_stripped.css("aside.quote").remove - cooked_stripped.css("pre").remove - cooked_stripped.css("code").remove - cooked_stripped.css(".onebox").remove - raw_mentions = cooked_stripped.css('.mention, .mention-group').map do |e| if name = e.inner_text name = name[1..-1] @@ -105,11 +97,10 @@ class PostAnalyzer @raw_links = [] - cooked_document.search("a").each do |l| + cooked_stripped.css("a[href]").each do |l| # Don't include @mentions in the link count - next if l.attributes['href'].nil? || link_is_a_mention?(l) - url = l.attributes['href'].to_s - @raw_links << url + next if l['href'].blank? || link_is_a_mention?(l) + @raw_links << l['href'].to_s end @raw_links @@ -122,13 +113,18 @@ class PostAnalyzer private - def cooked_document - @cooked_document ||= Nokogiri::HTML.fragment(cook(@raw, topic_id: @topic_id)) - end + def cooked_stripped + @cooked_stripped ||= begin + doc = Nokogiri::HTML.fragment(cook(@raw, topic_id: @topic_id)) + doc.css("pre, code, aside.quote, .onebox, .elided").remove + doc + end + end + + def link_is_a_mention?(l) + html_class = l['class'] + return false if html_class.blank? + html_class.to_s['mention'] && l['href'].to_s[/^\/users\//] + end - def link_is_a_mention?(l) - html_class = l.attributes['class'] - return false if html_class.nil? - html_class.to_s == 'mention' && l.attributes['href'].to_s =~ /^\/users\// - end end diff --git a/spec/models/post_analyzer_spec.rb b/spec/models/post_analyzer_spec.rb index 91cefd382b..ecda5cc6a0 100644 --- a/spec/models/post_analyzer_spec.rb +++ b/spec/models/post_analyzer_spec.rb @@ -38,8 +38,9 @@ describe PostAnalyzer do context "links" do let(:raw_no_links) { "hello world my name is evil trout" } let(:raw_one_link_md) { "[jlawr](http://www.imdb.com/name/nm2225369)" } - let(:raw_two_links_html) { "disney reddit"} - let(:raw_three_links) { "http://discourse.org and http://discourse.org/another_url and http://www.imdb.com/name/nm2225369"} + let(:raw_two_links_html) { "disney reddit" } + let(:raw_three_links) { "http://discourse.org and http://discourse.org/another_url and http://www.imdb.com/name/nm2225369" } + let(:raw_elided) { "
\n···\nhttp://discourse.org\n
" } describe "raw_links" do it "returns a blank collection for a post with no links" do @@ -61,6 +62,12 @@ describe PostAnalyzer do post_analyzer = PostAnalyzer.new(raw_three_links, default_topic_id) expect(post_analyzer.raw_links).to eq(["http://discourse.org", "http://discourse.org/another_url", "http://www.imdb.com/name/nm2225369"]) end + + it "doesn't extract links from elided part" do + post_analyzer = PostAnalyzer.new(raw_elided, default_topic_id) + post_analyzer.expects(:cook).returns("

\n···\ndiscourse.org\n

") + expect(post_analyzer.raw_links).to be_blank + end end describe "linked_hosts" do From e82084405ebdf88ff15c76bd2dafb221abf2d659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 5 Dec 2016 15:24:41 +0100 Subject: [PATCH 104/122] make eslint happy again --- .../discourse/pre-initializers/sniff-capabilities.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 index 593cdccf68..50bd993956 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -1,4 +1,4 @@ -/*global Modernizr:true*/ +/*global Modernizr:true safari:true*/ // Initializes an object that lets us know about our capabilities. export default { From 52763f5115a7fa7f8c592d373800b2d34ea20125 Mon Sep 17 00:00:00 2001 From: Erick Guan Date: Mon, 5 Dec 2016 13:31:43 +0100 Subject: [PATCH 105/122] FEATURE: Allow posting a link with topics --- .../discourse/components/composer-body.js.es6 | 5 +- .../discourse/controllers/history.js.es6 | 3 ++ .../discourse/controllers/topic.js.es6 | 8 ++++ .../helpers/topic-featured-link.js.es6 | 6 +++ .../lib/render-topic-featured-link.js.es6 | 46 +++++++++++++++++++ .../discourse/models/category.js.es6 | 12 +++++ .../discourse/models/composer.js.es6 | 40 ++++++++++++---- .../components/edit-category-settings.hbs | 11 +++++ .../templates/components/topic-category.hbs | 20 ++++---- .../discourse/templates/composer.hbs | 6 ++- .../templates/list/topic-list-item.raw.hbs | 3 ++ .../discourse/templates/modal/history.hbs | 7 +++ .../javascripts/discourse/templates/topic.hbs | 3 ++ .../widgets/header-topic-info.js.es6 | 10 +++- .../stylesheets/common/base/compose.scss | 4 ++ .../stylesheets/common/base/tagging.scss | 33 ++----------- app/assets/stylesheets/common/base/topic.scss | 21 ++++++++- .../common/components/badges.css.scss | 4 ++ app/assets/stylesheets/desktop/compose.scss | 9 ++++ .../stylesheets/desktop/topic-post.scss | 24 +++++++++- app/controllers/posts_controller.rb | 1 - app/helpers/application_helper.rb | 11 +++++ app/models/topic.rb | 13 ++++++ app/models/topic_list.rb | 2 + app/serializers/post_revision_serializer.rb | 4 ++ app/serializers/site_serializer.rb | 11 ++++- app/serializers/suggested_topic_serializer.rb | 10 +++- app/serializers/topic_list_item_serializer.rb | 11 ++++- app/serializers/topic_view_serializer.rb | 12 ++++- app/views/user_notifications/digest.html.erb | 6 +++ config/locales/client.en.yml | 2 + config/locales/server.en.yml | 8 ++++ config/site_settings.yml | 10 ++++ lib/discourse_featured_link.rb | 27 +++++++++++ lib/email/styles.rb | 5 ++ lib/guardian/category_guardian.rb | 5 ++ lib/guardian/topic_guardian.rb | 5 ++ lib/post_creator.rb | 20 +++++++- lib/post_revisor.rb | 17 +++++++ lib/topic_creator.rb | 4 ++ lib/validators/post_validator.rb | 11 +++-- spec/components/guardian_spec.rb | 23 ++++++++++ spec/components/post_creator_spec.rb | 9 ++++ .../validators/post_validator_spec.rb | 10 ++++ spec/controllers/posts_controller_spec.rb | 8 +--- .../fabricators/embeddable_host_fabricator.rb | 4 ++ spec/models/category_spec.rb | 10 ++-- spec/models/topic_spec.rb | 36 ++++++++++++++- spec/support/helpers.rb | 4 +- test/javascripts/models/composer-test.js.es6 | 6 ++- 50 files changed, 503 insertions(+), 77 deletions(-) create mode 100644 app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 create mode 100644 lib/discourse_featured_link.rb diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index d3c1e96d2e..aba22cf728 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -13,7 +13,8 @@ export default Ember.Component.extend({ 'composer.canEditTitle:edit-title', 'composer.createdPost:created-post', 'composer.creatingTopic:topic', - 'composer.whisper:composing-whisper'], + 'composer.whisper:composing-whisper', + 'composer.showComposerEditor::topic-featured-link-only'], @computed('composer.composeState') composeState(composeState) { @@ -27,7 +28,7 @@ export default Ember.Component.extend({ this.appEvents.trigger("composer:resized"); }, - @observes('composeState', 'composer.action') + @observes('composeState', 'composer.action', 'composer.canEditTopicFeaturedLink') resize() { Ember.run.scheduleOnce('afterRender', () => { if (!this.element || this.isDestroying || this.isDestroyed) { return; } diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 42550be880..e94852cf3e 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -21,6 +21,9 @@ export default Ember.Controller.extend(ModalFunctionality, { if (this.site.mobileView) { this.set("viewMode", "inline"); } }.on("init"), + previousFeaturedLink: Em.computed.alias('model.featured_link_changes.previous'), + currentFeaturedLink: Em.computed.alias('model.featured_link_changes.current'), + previousTagChanges: customTagArray('model.tags_changes.previous'), currentTagChanges: customTagArray('model.tags_changes.current'), diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 377aebd76d..4b9650fd98 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -160,6 +160,14 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return post => this.postSelected(post); }.property(), + @computed('model.isPrivateMessage', 'model.category.id') + canEditTopicFeaturedLink(isPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + @computed('model.isPrivateMessage') canEditTags(isPrivateMessage) { return !isPrivateMessage && this.site.get('can_tag_topics'); diff --git a/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 new file mode 100644 index 0000000000..686599e2b1 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 @@ -0,0 +1,6 @@ +import { registerUnbound } from 'discourse-common/lib/helpers'; +import renderTopicFeaturedLink from 'discourse/lib/render-topic-featured-link'; + +export default registerUnbound('topic-featured-link', function(topic, params) { + return new Handlebars.SafeString(renderTopicFeaturedLink(topic, params)); +}); diff --git a/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 new file mode 100644 index 0000000000..c8c3d640f8 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 @@ -0,0 +1,46 @@ +import { extractDomainFromUrl } from 'discourse/lib/utilities'; +import { h } from 'virtual-dom'; + +const _decorators = []; + +export function addFeaturedLinkMetaDecorator(decorator) { + _decorators.push(decorator); +} + +function extractLinkMeta(topic) { + const href = topic.featured_link, target = Discourse.SiteSettings.open_topic_featured_link_in_external_window ? '_blank' : ''; + if (!href) { return; } + + let domain = extractDomainFromUrl(href); + if (!domain) { return; } + + // www appears frequently, so we truncate it + if (domain && domain.substr(0, 4) === 'www.') { + domain = domain.substring(4); + } + + const meta = { target, href, domain, rel: 'nofollow' }; + if (_decorators.length) { + _decorators.forEach(cb => cb(meta)); + } + return meta; +} + +export default function renderTopicFeaturedLink(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return `${meta.domain}`; + } else { + return ''; + } +}; + +export function topicFeaturedLinkNode(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return h('a.topic-featured-link', { + attributes: { href: meta.href, rel: meta.rel, target: meta.target } + }, meta.domain); + } +} + diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index b77a4abdf3..fc56790194 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -169,6 +169,18 @@ const Category = RestModel.extend({ @computed("id") isUncategorizedCategory(id) { return id === Discourse.Site.currentProp("uncategorized_category_id"); + }, + + @computed('custom_fields.topic_featured_link_allowed') + topicFeaturedLinkAllowed: { + get(allowed) { + return allowed === "true"; + }, + set(value) { + value = value ? "true" : "false"; + this.set("custom_fields.topic_featured_link_allowed", value); + return value; + } } }); diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index e782c4074a..52d8cde6ae 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -32,13 +32,15 @@ const CLOSED = 'closed', target_usernames: 'targetUsernames', typing_duration_msecs: 'typingTime', composer_open_duration_msecs: 'composerTime', - tags: 'tags' + tags: 'tags', + featured_link: 'featuredLink' }, _edit_topic_serializer = { title: 'topic.title', categoryId: 'topic.category.id', - tags: 'topic.tags' + tags: 'topic.tags', + featuredLink: 'topic.featured_link' }; const Composer = RestModel.extend({ @@ -136,6 +138,14 @@ const Composer = RestModel.extend({ canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), + @computed('canEditTitle', 'creatingPrivateMessage', 'categoryId') + canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || !canEditTitle || creatingPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + // Determine the appropriate title for this action actionTitle: function() { const topic = this.get('topic'); @@ -180,6 +190,10 @@ const Composer = RestModel.extend({ }.property('action', 'post', 'topic', 'topic.title'), + @computed('canEditTopicFeaturedLink') + showComposerEditor(canEditTopicFeaturedLink) { + return canEditTopicFeaturedLink ? !this.siteSettings.topic_featured_link_onebox : true; + }, // whether to disable the post button cantSubmitPost: function() { @@ -269,11 +283,12 @@ const Composer = RestModel.extend({ } }.property('privateMessage'), - missingReplyCharacters: function() { - const postType = this.get('post.post_type'); - if (postType === this.site.get('post_types.small_action')) { return 0; } - return this.get('minimumPostLength') - this.get('replyLength'); - }.property('minimumPostLength', 'replyLength'), + @computed('minimumPostLength', 'replyLength', 'canEditTopicFeaturedLink') + missingReplyCharacters(minimumPostLength, replyLength, canEditTopicFeaturedLink) { + if (this.get('post.post_type') === this.site.get('post_types.small_action') || + canEditTopicFeaturedLink && this.siteSettings.topic_featured_link_onebox) { return 0; } + return minimumPostLength - replyLength; + }, /** Minimum number of characters for a post body to be valid. @@ -492,6 +507,14 @@ const Composer = RestModel.extend({ save(opts) { if (!this.get('cantSubmitPost')) { + + // change category may result in some effect for topic featured link + if (this.get('canEditTopicFeaturedLink')) { + if (this.siteSettings.topic_featured_link_onebox) { this.set('reply', null); } + } else { + this.set('featuredLink', null); + } + return this.get('editingPost') ? this.editPost(opts) : this.createPost(opts); } }, @@ -512,7 +535,8 @@ const Composer = RestModel.extend({ stagedPost: false, typingTime: 0, composerOpened: null, - composerTotalOpened: 0 + composerTotalOpened: 0, + featuredLink: null }); }, diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs index 72883cba5a..790e61e939 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -19,6 +19,17 @@
+{{#if siteSettings.topic_featured_link_enabled}} +
+ +
+{{/if}} +