diff --git a/app/assets/javascripts/discourse/app/components/quote-button.js b/app/assets/javascripts/discourse/app/components/quote-button.js index 2b932be2d67..1672857343e 100644 --- a/app/assets/javascripts/discourse/app/components/quote-button.js +++ b/app/assets/javascripts/discourse/app/components/quote-button.js @@ -2,8 +2,16 @@ import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import discourseDebounce from "discourse/lib/debounce"; import toMarkdown from "discourse/lib/to-markdown"; -import { selectedText, selectedElement } from "discourse/lib/utilities"; +import { + selectedText, + selectedElement, + postUrl +} from "discourse/lib/utilities"; +import { getAbsoluteURL } from "discourse-common/lib/get-url"; import { INPUT_DELAY } from "discourse-common/config/environment"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; +import Sharing from "discourse/lib/sharing"; function getQuoteTitle(element) { const titleEl = element.querySelector(".title"); @@ -201,8 +209,59 @@ export default Component.extend({ .off("selectionchange.quote-button"); }, - click() { + @discourseComputed + quoteSharingEnabled() { + if ( + this.site.mobileView || + this.siteSettings.share_quote_visibility === "none" || + this.quoteSharingSources.length === 0 || + (this.currentUser && + this.siteSettings.share_quote_visibility === "anonymous") + ) { + return false; + } + + return true; + }, + + @discourseComputed("topic.isPrivateMessage") + quoteSharingSources(isPM) { + return Sharing.activeSources( + this.siteSettings.share_quote_buttons, + this.siteSettings.login_required || isPM + ); + }, + + @discourseComputed + quoteSharingShowLabel() { + return this.quoteSharingSources.length > 1; + }, + + @discourseComputed("topic.{id,slug}", "quoteState") + shareUrl(topic, quoteState) { + return getAbsoluteURL(postUrl(topic.slug, topic.id, quoteState.postId)); + }, + + @discourseComputed("topic.details.can_create_post", "composer.visible") + embedQuoteButton(canCreatePost, composerOpened) { + return ( + (canCreatePost || composerOpened) && + this.currentUser && + this.currentUser.get("enable_quoting") + ); + }, + + @action + insertQuote() { this.attrs.selectText().then(() => this._hideButton()); - return false; + }, + + @action + share(source) { + Sharing.shareSource(source, { + url: this.shareUrl, + title: this.topic.title, + quote: window.getSelection().toString() + }); } }); diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index eba29fa2af2..54e69b2770c 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -77,15 +77,6 @@ export default Controller.extend(bufferedProperty("model"), { } }, - @discourseComputed("model.details.can_create_post", "composer.visible") - embedQuoteButton(canCreatePost, composerOpened) { - return ( - (canCreatePost || composerOpened) && - this.currentUser && - this.currentUser.get("enable_quoting") - ); - }, - @discourseComputed("model.postStream.loaded", "model.category_id") showSharedDraftControls(loaded, categoryId) { let draftCat = this.site.shared_drafts_category_id; diff --git a/app/assets/javascripts/discourse/app/initializers/sharing-sources.js b/app/assets/javascripts/discourse/app/initializers/sharing-sources.js index 127a6e55782..bb0fd892356 100644 --- a/app/assets/javascripts/discourse/app/initializers/sharing-sources.js +++ b/app/assets/javascripts/discourse/app/initializers/sharing-sources.js @@ -4,17 +4,17 @@ import Sharing from "discourse/lib/sharing"; export default { name: "sharing-sources", - initialize: function() { + initialize: function(container) { + const siteSettings = container.lookup("site-settings:main"); + Sharing.addSource({ id: "twitter", icon: "fab-twitter-square", - generateUrl: function(link, title) { - return ( - "http://twitter.com/intent/tweet?url=" + - encodeURIComponent(link) + - "&text=" + - encodeURIComponent(title) - ); + generateUrl: function(link, title, quote = "") { + const text = quote ? `"${quote}" -- ` : title; + return `http://twitter.com/intent/tweet?url=${encodeURIComponent( + link + )}&text=${encodeURIComponent(text)}`; }, shouldOpenInPopup: true, title: I18n.t("share.twitter"), @@ -25,13 +25,14 @@ export default { id: "facebook", icon: "fab-facebook", title: I18n.t("share.facebook"), - generateUrl: function(link, title) { - return ( - "http://www.facebook.com/sharer.php?u=" + - encodeURIComponent(link) + - "&t=" + - encodeURIComponent(title) - ); + generateUrl: function(link, title, quote = "") { + const fb_url = siteSettings.facebook_app_id + ? `https://www.facebook.com/dialog/share?app_id=${ + siteSettings.facebook_app_id + }"e=${encodeURIComponent(quote)}&href=` + : "https://www.facebook.com/sharer.php?u="; + + return `${fb_url}${encodeURIComponent(link)}`; }, shouldOpenInPopup: true }); @@ -40,17 +41,18 @@ export default { id: "email", icon: "envelope-square", title: I18n.t("share.email"), - showInPrivateContext: true, - generateUrl: function(link, title) { + generateUrl: function(link, title, quote = "") { + const body = quote ? `${quote} \n\n ${link}` : link; return ( "mailto:?to=&subject=" + encodeURIComponent( "[" + Discourse.SiteSettings.title + "] " + title ) + "&body=" + - encodeURIComponent(link) + encodeURIComponent(body) ); - } + }, + showInPrivateContext: true }); } }; diff --git a/app/assets/javascripts/discourse/app/lib/sharing.js b/app/assets/javascripts/discourse/app/lib/sharing.js index aa7b0a516b8..5c0dfe5da1e 100644 --- a/app/assets/javascripts/discourse/app/lib/sharing.js +++ b/app/assets/javascripts/discourse/app/lib/sharing.js @@ -59,7 +59,7 @@ export default { if (source.clickHandler) { source.clickHandler(data.url, data.title); } else { - const url = source.generateUrl(data.url, data.title); + const url = source.generateUrl(data.url, data.title, data.quote); const options = { menubar: "no", toolbar: "no", @@ -74,6 +74,8 @@ export default { if (source.shouldOpenInPopup) { window.open(url, "", stringOptions); + } else if (source.id === "email") { + window.location.href = url; } else { window.open(url, "_blank"); } diff --git a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs b/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs index 868baa320f5..a28dd8e26ed 100644 --- a/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/quote-button.hbs @@ -1 +1,31 @@ -{{d-icon "quote-left"}} {{i18n "post.quote_reply"}} +{{#if embedQuoteButton}} + {{d-button + class="btn-flat insert-quote" + action=(action "insertQuote") + icon="quote-left" + label="post.quote_reply"}} +{{/if}} + +{{#if quoteSharingEnabled}} + + {{#if quoteSharingShowLabel}} + {{d-button + icon="share" + label="post.quote_share" + class="btn-flat quote-share-label"}} + {{/if}} + + + {{#each quoteSharingSources as |source|}} + {{d-button + class="btn-flat" + action=(action "share" source) + translatedTitle=source.title + icon=source.icon}} + {{/each}} + {{plugin-outlet name="quote-share-buttons-after" tagName=""}} + + +{{/if}} + +{{plugin-outlet name="quote-button-after" tagName=""}} diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs index 33f11793a80..f2cddfa09c1 100644 --- a/app/assets/javascripts/discourse/app/templates/topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/topic.hbs @@ -397,7 +397,5 @@ {{share-popup topic=model replyAsNewTopic=(action "replyAsNewTopic")}} - {{#if embedQuoteButton}} - {{quote-button quoteState=quoteState selectText=(action "selectText")}} - {{/if}} + {{quote-button quoteState=quoteState selectText=(action "selectText") topic=model composerVisible=composer.visible}} {{/discourse-topic}} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 3b2424c2ac8..34746036722 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -1,3 +1,5 @@ +$quote-share-maxwidth: 150px; + .button-count.has-pending { span { background-color: $danger; @@ -299,19 +301,65 @@ blockquote { .quote-button { display: none; position: absolute; - background-color: blend-primary-secondary(50%); - color: dark-light-choose($secondary, $primary); - padding: 10px; z-index: z("dropdown"); opacity: 0.9; + background-color: dark-light-choose( + blend-primary-secondary(60%), + blend-primary-secondary(30%) + ); - .d-icon { - display: inline-block; + &.visible { + display: block; } - &:hover { - background-color: $primary-medium; - cursor: pointer; + .btn, + .btn:hover, + .d-icon, + .btn:hover .d-icon { + color: dark-light-choose($secondary, $primary); + } + + .insert-quote + .quote-sharing { + border-left: 1px solid + dark-light-choose(rgba($secondary, 0.4), rgba($primary, 0.4)); + } + + .quote-sharing { + vertical-align: middle; + display: inline-flex; + align-items: center; + + .btn { + display: inline-flex; + align-items: center; + } + + .quote-share-label { + opacity: 1; + max-width: $quote-share-maxwidth; + transition: opacity 0.3s ease-in-out, max-width 0.3s ease-in-out, + padding 0.3s ease-in-out; + } + + &:hover .quote-share-label { + background: transparent; + opacity: 0; + max-width: 0px; + padding: 6px 0px; + } + + .quote-share-label + .quote-share-buttons { + opacity: 0; + overflow: hidden; + max-width: 0; + display: inline-flex; + transition: opacity 0.3s ease-in-out, max-width 0.3s ease-in-out; + } + + &:hover .quote-share-label + .quote-share-buttons { + max-width: $quote-share-maxwidth; + opacity: 1; + } } } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index ed1b906a302..4a06c3b9bd5 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -465,10 +465,6 @@ button.expand-post { margin-left: $topic-body-width-padding; } -.quote-button.visible { - display: block; -} - iframe { max-width: 100%; } diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index 7031810bc01..4a3ca7d968d 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -279,7 +279,6 @@ span.post-count { } .quote-button.visible { - display: block; z-index: z("tooltip"); } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d4eee0c8c74..404a8c14811 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -151,9 +151,9 @@ en: topic_html: 'Topic: %{topicTitle}' post: "post #%{postNumber}" close: "close" - twitter: "Share this link on Twitter" - facebook: "Share this link on Facebook" - email: "Send this link in an email" + twitter: "Share on Twitter" + facebook: "Share on Facebook" + email: "Send via email" action_codes: public_topic: "made this topic public %{when}" @@ -2597,6 +2597,7 @@ en: post: quote_reply: "Quote" + quote_share: "Share" edit_reason: "Reason: " post_number: "post %{number}" ignored: "Ignored content" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 102879cb257..bc281ef10fe 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -208,6 +208,7 @@ en: s3_backup_requires_s3_settings: "You cannot use S3 as backup location unless you've provided the '%{setting_name}'." s3_bucket_reused: "You cannot use the same bucket for 's3_upload_bucket' and 's3_backup_bucket'. Choose a different bucket or use a different path for each bucket." secure_media_requirements: "S3 uploads must be enabled before enabling secure media." + share_quote_facebook_requirements: "You must set a Facebook app id to enable quote sharing for Facebook." second_factor_cannot_be_enforced_with_disabled_local_login: "You cannot enforce 2FA if local logins are disabled." local_login_cannot_be_disabled_if_second_factor_enforced: "You cannot disable local login if 2FA is enforced. Disable enforced 2FA before disabling local logins." cannot_enable_s3_uploads_when_s3_enabled_globally: "You cannot enable S3 uploads because S3 uploads are already globally enabled, and enabling this site-level could cause critical issues with uploads" @@ -1666,7 +1667,7 @@ en: instagram_consumer_secret: "Consumer secret Instagram authentication" enable_facebook_logins: "Enable Facebook authentication, requires facebook_app_id and facebook_app_secret. See Configuring Facebook login for Discourse." - facebook_app_id: "App id for Facebook authentication, registered at https://developers.facebook.com/apps" + facebook_app_id: "App id for Facebook authentication and sharing, registered at https://developers.facebook.com/apps" facebook_app_secret: "App secret for Facebook authentication, registered at https://developers.facebook.com/apps" enable_github_logins: "Enable Github authentication, requires github_client_id and github_client_secret. See Configuring GitHub login for Discourse." @@ -2233,6 +2234,9 @@ en: gravatar_base_url: "Url of the Gravatar provider's API base" gravatar_login_url: "Url relative to gravatar_base_url, which provides the user with the login to the Gravatar service" + share_quote_buttons: "Determine which items appear in the quote sharing widget, and in what order." + share_quote_visibility: "Determine when to show quote sharing buttons: never, to anonymous users only or all users. " + errors: invalid_email: "Invalid email address." invalid_username: "There's no user with that username." diff --git a/config/site_settings.yml b/config/site_settings.yml index aeab2e006ee..03c0d951f09 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -197,6 +197,23 @@ basic: - twitter - facebook - email + share_quote_visibility: + client: true + type: enum + default: "anonymous" + choices: + - none + - anonymous + - all + share_quote_buttons: + client: true + type: list + default: "twitter|email" + allow_any: false + choices: + - twitter + - facebook + - email desktop_category_page_style: client: true enum: "CategoryPageStyle" @@ -354,6 +371,7 @@ login: enable_facebook_logins: default: false facebook_app_id: + client: true default: "" regex: "^\\d+$" facebook_app_secret: @@ -1748,7 +1766,7 @@ backups: search: search_ranking_normalization: - default: '0' + default: "0" hidden: true min_search_term_length: client: true diff --git a/lib/site_settings/validations.rb b/lib/site_settings/validations.rb index 0bf13f7606e..4160a175d25 100644 --- a/lib/site_settings/validations.rb +++ b/lib/site_settings/validations.rb @@ -127,6 +127,10 @@ module SiteSettings::Validations validate_error :secure_media_requirements if new_val == "t" && !SiteSetting.Upload.enable_s3_uploads end + def validate_share_quote_buttons(new_val) + validate_error :share_quote_facebook_requirements if new_val.include?("facebook") && SiteSetting.facebook_app_id.blank? + end + def validate_enable_s3_inventory(new_val) validate_error :enable_s3_uploads_is_required if new_val == "t" && !SiteSetting.Upload.enable_s3_uploads end diff --git a/test/javascripts/acceptance/topic-quote-button-test.js b/test/javascripts/acceptance/topic-quote-button-test.js new file mode 100644 index 00000000000..181ec543135 --- /dev/null +++ b/test/javascripts/acceptance/topic-quote-button-test.js @@ -0,0 +1,122 @@ +import I18n from "I18n"; +import { acceptance } from "helpers/qunit-helpers"; + +function selectText(selector) { + const range = document.createRange(); + const node = document.querySelector(selector); + range.selectNodeContents(node); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +acceptance("Topic - Quote button - logged in", { + loggedIn: true, + settings: { + share_quote_visibility: "anonymous", + share_quote_buttons: "twitter|email" + } +}); + +QUnit.test("Does not show the quote share buttons by default", async assert => { + await visit("/t/internationalization-localization/280"); + selectText("#post_5 blockquote"); + assert.ok(exists(".insert-quote"), "it shows the quote button"); + assert.equal( + find(".quote-sharing").length, + 0, + "it does not show quote sharing" + ); +}); + +QUnit.test( + "Shows quote share buttons with the right site settings", + async function(assert) { + this.siteSettings.share_quote_visibility = "all"; + + await visit("/t/internationalization-localization/280"); + selectText("#post_5 blockquote"); + + assert.ok(exists(".quote-sharing"), "it shows the quote sharing options"); + assert.ok( + exists(`.quote-sharing .btn[title='${I18n.t("share.twitter")}']`), + "it includes the twitter share button" + ); + assert.ok( + exists(`.quote-sharing .btn[title='${I18n.t("share.email")}']`), + "it includes the email share button" + ); + } +); + +acceptance("Topic - Quote button - anonymous", { + loggedIn: false, + settings: { + share_quote_visibility: "anonymous", + share_quote_buttons: "twitter|email" + } +}); + +QUnit.test( + "Shows quote share buttons with the right site settings", + async function(assert) { + await visit("/t/internationalization-localization/280"); + selectText("#post_5 blockquote"); + + assert.ok(find(".quote-sharing"), "it shows the quote sharing options"); + assert.ok( + exists(`.quote-sharing .btn[title='${I18n.t("share.twitter")}']`), + "it includes the twitter share button" + ); + assert.ok( + exists(`.quote-sharing .btn[title='${I18n.t("share.email")}']`), + "it includes the email share button" + ); + assert.equal( + find(".insert-quote").length, + 0, + "it does not show the quote button" + ); + } +); + +QUnit.test( + "Shows single share button when site setting only has one item", + async function(assert) { + this.siteSettings.share_quote_buttons = "twitter"; + + await visit("/t/internationalization-localization/280"); + selectText("#post_5 blockquote"); + + assert.ok(exists(".quote-sharing"), "it shows the quote sharing options"); + assert.ok( + exists(`.quote-sharing .btn[title='${I18n.t("share.twitter")}']`), + "it includes the twitter share button" + ); + assert.equal( + find(".quote-share-label").length, + 0, + "it does not show the Share label" + ); + } +); + +QUnit.test("Shows nothing when visibility is disabled", async function(assert) { + this.siteSettings.share_quote_visibility = "none"; + + await visit("/t/internationalization-localization/280"); + selectText("#post_5 blockquote"); + + assert.equal( + find(".quote-sharing").length, + 0, + "it does not show quote sharing" + ); + + assert.equal( + find(".insert-quote").length, + 0, + "it does not show the quote button" + ); +}); diff --git a/test/javascripts/acceptance/topic-test.js b/test/javascripts/acceptance/topic-test.js index f795e56f8fc..4e4cb0866fa 100644 --- a/test/javascripts/acceptance/topic-test.js +++ b/test/javascripts/acceptance/topic-test.js @@ -341,7 +341,7 @@ function selectText(selector) { QUnit.test("Quoting a quote keeps the original poster name", async assert => { await visit("/t/internationalization-localization/280"); selectText("#post_5 blockquote"); - await click(".quote-button"); + await click(".quote-button .insert-quote"); assert.ok( find(".d-editor-input") @@ -386,7 +386,7 @@ QUnit.test( async assert => { await visit("/t/internationalization-localization/280"); selectText("#post_5 .cooked"); - await click(".quote-button"); + await click(".quote-button .insert-quote"); assert.ok( find(".d-editor-input")