diff --git a/.gitignore b/.gitignore index 131364d5b5b..4268486d7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ bundler_stubs/* vendor/bundle/* *.db + +#ignore jetbrains ide file +*.iml diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 71a8ca8fc12..067f6510814 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -687,6 +687,12 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this.send('togglePinnedForUser'); }, + print() { + if (this.siteSettings.max_prints_per_hour_per_user > 0) { + window.open(this.get('model.printUrl'), '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=600,height=315'); + } + }, + canMergeTopic: function() { if (!this.get('model.details.can_move_posts')) return false; return this.get('selectedPostsCount') > 0; diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index 5701e4b735e..49e543a52ee 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -13,6 +13,8 @@ const bindings = { 'c': {handler: 'createTopic'}, 'ctrl+f': {handler: 'showPageSearch', anonymous: true}, 'command+f': {handler: 'showPageSearch', anonymous: true}, + 'ctrl+p': {handler: 'printTopic', anonymous: true}, + 'command+p': {handler: 'printTopic', anonymous: true}, 'd': {postAction: 'deletePost'}, 'e': {postAction: 'editPost'}, 'end': {handler: 'goToLastPost', anonymous: true}, @@ -151,6 +153,15 @@ export default { }); }, + printTopic(event) { + Ember.run(() => { + if ($('.container.posts').length) { + event.preventDefault(); // We need to stop printing the current page in Firefox + this.container.lookup('controller:topic').print(); + } + }); + }, + createTopic() { this.container.lookup('controller:composer').open({action: Composer.CREATE_TOPIC, draftKey: Composer.CREATE_TOPIC}); }, diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index f47342d7665..15b181fae88 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -134,6 +134,11 @@ const Topic = RestModel.extend({ return this.get('url') + (user ? '?u=' + user.get('username_lower') : ''); }.property('url'), + @computed('url') + printUrl(url) { + return url + '/print'; + }, + url: function() { let slug = this.get('slug') || ''; if (slug.trim().length === 0) { diff --git a/app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs b/app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs index 4b1053397a2..3e6adc0fd25 100644 --- a/app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs +++ b/app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs @@ -56,6 +56,7 @@
  • {{{i18n 'keyboard_shortcuts_help.actions.mark_regular'}}}
  • {{{i18n 'keyboard_shortcuts_help.actions.mark_tracking'}}}
  • {{{i18n 'keyboard_shortcuts_help.actions.mark_watching'}}}
  • +
  • {{{i18n 'keyboard_shortcuts_help.actions.print'}}}
  • diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss index 098bfa420c5..04045f843f9 100644 --- a/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss +++ b/app/assets/stylesheets/common/components/keyboard_shortcuts.css.scss @@ -11,7 +11,7 @@ } .keyboard-shortcuts-modal .modal-body { - max-height: 520px; + max-height: 560px; } #keyboard-shortcuts-help { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d9b6e5e6015..ec302c39666 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -53,7 +53,7 @@ class ApplicationController < ActionController::Base end def use_crawler_layout? - @use_crawler_layout ||= (has_escaped_fragment? || CrawlerDetection.crawler?(request.user_agent)) + @use_crawler_layout ||= (has_escaped_fragment? || CrawlerDetection.crawler?(request.user_agent) || params.key?("print")) end def add_readonly_header diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index d890410469a..1a15adc5b5d 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -3,6 +3,7 @@ require_dependency 'promotion' require_dependency 'url_helper' require_dependency 'topics_bulk_action' require_dependency 'discourse_event' +require_dependency 'rate_limiter' class TopicsController < ApplicationController before_filter :ensure_logged_in, only: [:timings, @@ -58,6 +59,7 @@ class TopicsController < ApplicationController username_filters = opts[:username_filters] opts[:slow_platform] = true if slow_platform? + opts[:print] = true if params[:print].present? opts[:username_filters] = username_filters.split(',') if username_filters.is_a?(String) # Special case: a slug with a number in front should look by slug first before looking @@ -67,6 +69,15 @@ class TopicsController < ApplicationController return redirect_to_correct_topic(topic, opts[:post_number]) if topic && topic.visible end + if opts[:print] + raise Discourse::InvalidAccess unless SiteSetting.max_prints_per_hour_per_user > 0 + begin + RateLimiter.new(current_user, "print-topic-per-hour", SiteSetting.max_prints_per_hour_per_user, 1.hour).performed! unless @guardian.is_admin? + rescue RateLimiter::LimitExceeded + render_json_error(I18n.t("rate_limiter.slow_down")) + end + end + begin @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts) rescue Discourse::NotFound diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index a2dfcf11a0b..0446c81f0bf 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -43,6 +43,11 @@ .topic-list > div { margin-bottom: 15px; } + body img.emoji { + width: 20px; + height: 20px; + vertical-align: middle; + } @@ -71,4 +76,5 @@ <%= raw SiteCustomization.custom_body_tag(session[:preview_style]) %> <%- end %> + <%= yield :after_body %> diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index 6542cb3967d..620b1b54f09 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -66,3 +66,13 @@ <% end %> <% content_for(:title) { "#{@topic_view.page_title}" } %> + +<% if @topic_view.print %> + <% content_for :after_body do %> + + <% end %> +<% end %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2422d36f684..49547abec9b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1502,6 +1502,10 @@ en: title: 'Share' help: 'share a link to this topic' + print: + title: 'Print' + help: 'Open a printer friendly version of this topic' + flag_topic: title: 'Flag' help: 'privately flag this topic for attention or send a private notification about it' @@ -2161,6 +2165,7 @@ en: mark_regular: 'm, r Regular (default) topic' mark_tracking: 'm, t Track topic' mark_watching: 'm, w Watch topic' + print: 'ctrl+p Print topic' badges: earned_n_times: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2c3c29edb5f..e6acba16bc3 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1307,6 +1307,8 @@ en: topic_page_title_includes_category: "Topic page title includes the category name." + max_prints_per_hour_per_user: "Maximum number of /print page impressions (set to 0 to disable)" + full_name_required: "Full name is a required field of a user's profile." enable_names: "Show the user's full name on their profile, user card, and emails. Disable to hide full name everywhere." display_name_on_posts: "Show a user's full name on their posts in addition to their @username." diff --git a/config/routes.rb b/config/routes.rb index df0cb578453..3f8aa8dd7cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -554,6 +554,7 @@ Discourse::Application.routes.draw do # Topic routes get "t/id_for/:slug" => "topics#id_for_slug" + get "t/:slug/:topic_id/print" => "topics#show", format: :html, print: true, constraints: {topic_id: /\d+/} get "t/:slug/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/} get "t/:topic_id/wordpress" => "topics#wordpress", constraints: {topic_id: /\d+/} get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/} diff --git a/config/site_settings.yml b/config/site_settings.yml index 68d0d622ea4..cd58b85547f 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -915,6 +915,9 @@ rate_limits: client: true shadowed_by_global: true default: 0 + max_prints_per_hour_per_user: + default: 5 + client: true developer: force_hostname: @@ -1226,6 +1229,7 @@ uncategorized: topic_page_title_includes_category: true + user_preferences: default_email_digest_frequency: enum: 'DigestEmailSiteSetting' diff --git a/lib/topic_view.rb b/lib/topic_view.rb index f69d3e47167..7c93adb8698 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -5,13 +5,17 @@ require_dependency 'gaps' class TopicView - attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size + attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size, :print attr_accessor :draft, :draft_key, :draft_sequence, :user_custom_fields, :post_custom_fields def self.slow_chunk_size 10 end + def self.print_chunk_size + 1000 + end + def self.chunk_size 20 end @@ -37,6 +41,7 @@ class TopicView @user = user @guardian = Guardian.new(@user) @topic = find_topic(topic_id) + @print = options[:print].present? check_and_raise_exceptions options.each do |key, value| @@ -44,7 +49,11 @@ class TopicView end @page = 1 if (!@page || @page.zero?) - @chunk_size = options[:slow_platform] ? TopicView.slow_chunk_size : TopicView.chunk_size + @chunk_size = case + when options[:slow_platform] then TopicView.slow_chunk_size + when @print then TopicView.print_chunk_size + else TopicView.chunk_size + end @limit ||= @chunk_size setup_filtered_posts @@ -71,7 +80,7 @@ class TopicView end def canonical_path - path = @topic.relative_url + path = relative_url path << if @post_number page = ((@post_number.to_i - 1) / @limit) + 1 (page > 1) ? "?page=#{page}" : "" @@ -113,22 +122,22 @@ class TopicView def prev_page_path if prev_page > 1 - "#{@topic.relative_url}?page=#{prev_page}" + "#{relative_url}?page=#{prev_page}" else - @topic.relative_url + relative_url end end def next_page_path - "#{@topic.relative_url}?page=#{next_page}" + "#{relative_url}?page=#{next_page}" end def absolute_url - "#{Discourse.base_url}#{@topic.relative_url}" + "#{Discourse.base_url}#{relative_url}" end def relative_url - @topic.relative_url + "#{@topic.relative_url}#{@print ? '/print' : ''}" end def page_title diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index b193b9058fc..b31dd1264d7 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -35,6 +35,11 @@ describe TopicView do tv = TopicView.new(topic.id, coding_horror, slow_platform: true) expect(tv.chunk_size).to eq(TopicView.slow_chunk_size) end + + it "returns `print_chunk_size` when print param is true" do + tv = TopicView.new(topic.id, coding_horror, print: true) + expect(tv.chunk_size).to eq(TopicView.print_chunk_size) + end end context "with a few sample posts" do diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 1d46068aac6..62b9bb5afd8 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -84,6 +84,24 @@ describe TopicsController do end + describe "print" do + render_views + + context "when the SiteSetting is enabled" do + it "uses the application layout when there's no param" do + get :show, topic_id: topic.id, slug: topic.slug + expect(response).to render_template(layout: 'application') + assert_select "meta[name=fragment]", true, "it has the meta tag" + end + + it "uses the crawler layout when there's an print param" do + get :show, topic_id: topic.id, slug: topic.slug, print: 'true' + expect(response).to render_template(layout: 'crawler') + assert_select "meta[name=fragment]", false, "it doesn't have the meta tag" + end + end + end + describe 'clear_notifications' do it 'correctly clears notifications if specified via cookie' do notification = Fabricate(:notification) diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index eddb73bd0ee..ee523583bd2 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -747,6 +747,21 @@ describe TopicsController do expect(IncomingLink.count).to eq(1) end + context 'print' do + + it "doesn't renders the print view when disabled" do + SiteSetting.max_prints_per_hour_per_user = 0 + get :show, topic_id: topic.id, slug: topic.slug, print: true + expect(response).to be_forbidden + end + + it 'renders the print view when enabled' do + SiteSetting.max_prints_per_hour_per_user = 10 + get :show, topic_id: topic.id, slug: topic.slug, print: true + expect(response).to be_successful + end + end + it 'records redirects' do @request.env['HTTP_REFERER'] = 'http://twitter.com' get :show, { id: topic.id }