From d5fe9b4f8cb12b827d45eadc3c559e879d436f90 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 8 Dec 2023 11:45:49 +1000 Subject: [PATCH] FEATURE: Add copy link post menu button (#24709) This commit ports the feature by @chapoi that was previously a theme component in core. A new post_menu button, copyLink, is added and used as the default instead of share. copyLink, on desktop, will copy the link of the post to the user's clipboard and show a nice 'lil animation. On mobile the native share menu will be shown. If site owners want the old behaviour back, they just need to change the post_menu site setting to use the share button instead of copyLink. --- .../addon/lib/icon-library.js | 2 +- .../discourse/app/lib/copy-post-link.js | 64 ++++++++++++++++ .../discourse/app/widgets/post-menu.js | 9 +++ .../javascripts/discourse/app/widgets/post.js | 39 +++++++++- .../discourse/tests/acceptance/topic-test.js | 13 ++++ .../components/widgets/post-test.js | 14 +++- app/assets/stylesheets/desktop/_index.scss | 1 + .../stylesheets/desktop/post-action-menu.scss | 76 +++++++++++++++++++ config/locales/client.en.yml | 2 + config/site_settings.yml | 5 +- 10 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/lib/copy-post-link.js create mode 100644 app/assets/stylesheets/desktop/post-action-menu.scss diff --git a/app/assets/javascripts/discourse-common/addon/lib/icon-library.js b/app/assets/javascripts/discourse-common/addon/lib/icon-library.js index a1a64b08c94..8d3d8f3ffe1 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/icon-library.js +++ b/app/assets/javascripts/discourse-common/addon/lib/icon-library.js @@ -5,7 +5,7 @@ import deprecated from "discourse-common/lib/deprecated"; import escape from "discourse-common/lib/escape"; import I18n from "discourse-i18n"; -const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +export const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; let _renderers = []; let warnMissingIcons = true; diff --git a/app/assets/javascripts/discourse/app/lib/copy-post-link.js b/app/assets/javascripts/discourse/app/lib/copy-post-link.js new file mode 100644 index 00000000000..b29ec01e470 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/copy-post-link.js @@ -0,0 +1,64 @@ +import { SVG_NAMESPACE } from "discourse-common/lib/icon-library"; +import I18n from "discourse-i18n"; + +export function recentlyCopiedPostLink(postId) { + return document.querySelector( + `article[data-post-id='${postId}'] .post-action-menu__copy-link .post-action-menu__copy-link-checkmark` + ); +} + +export function showCopyPostLinkAlert(postId) { + const postSelector = `article[data-post-id='${postId}']`; + const copyLinkBtn = document.querySelector( + `${postSelector} .post-action-menu__copy-link` + ); + createAlert(I18n.t("post.controls.link_copied"), postId, copyLinkBtn); + createCheckmark(copyLinkBtn, postId); + styleLinkBtn(copyLinkBtn); +} + +function createAlert(message, postId, copyLinkBtn) { + if (!copyLinkBtn) { + return; + } + + let alertDiv = document.createElement("div"); + alertDiv.className = "post-link-copied-alert -success"; + alertDiv.textContent = message; + + copyLinkBtn.appendChild(alertDiv); + + setTimeout(() => alertDiv.classList.add("slide-out"), 1000); + setTimeout(() => removeElement(alertDiv), 2500); +} + +function createCheckmark(btn, postId) { + const checkmark = makeCheckmarkSvg(postId); + btn.appendChild(checkmark.content); + + setTimeout(() => checkmark.classList.remove("is-visible"), 3000); + setTimeout( + () => + removeElement(document.querySelector(`#copy_post_svg_postId_${postId}`)), + 3500 + ); +} + +function styleLinkBtn(copyLinkBtn) { + copyLinkBtn.classList.add("is-copied"); + setTimeout(() => copyLinkBtn.classList.remove("is-copied"), 3200); +} + +function makeCheckmarkSvg(postId) { + const svgElement = document.createElement("template"); + svgElement.innerHTML = ` + + + + `; + return svgElement; +} + +function removeElement(element) { + element?.parentNode?.removeChild(element); +} diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js index df0efe45c8d..0c25d991114 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js @@ -331,9 +331,18 @@ registerButton("replies", (attrs, state, siteSettings) => { registerButton("share", () => { return { action: "share", + icon: "d-post-share", className: "share", title: "post.controls.share", + }; +}); + +registerButton("copyLink", () => { + return { + action: "copyLink", icon: "d-post-share", + className: "post-action-menu__copy-link", + title: "post.controls.copy_title", }; }); diff --git a/app/assets/javascripts/discourse/app/widgets/post.js b/app/assets/javascripts/discourse/app/widgets/post.js index abf831dc847..fa5e0e95b60 100644 --- a/app/assets/javascripts/discourse/app/widgets/post.js +++ b/app/assets/javascripts/discourse/app/widgets/post.js @@ -4,6 +4,10 @@ import { h } from "virtual-dom"; import ShareTopicModal from "discourse/components/modal/share-topic"; import { dateNode } from "discourse/helpers/node"; import autoGroupFlairForUser from "discourse/lib/avatar-flair"; +import { + recentlyCopiedPostLink, + showCopyPostLinkAlert, +} from "discourse/lib/copy-post-link"; import { relativeAgeMediumSpan } from "discourse/lib/formatter"; import { nativeShare } from "discourse/lib/pwa-utils"; import { @@ -12,13 +16,14 @@ import { } from "discourse/lib/settings"; import { transformBasicPost } from "discourse/lib/transform-post"; import DiscourseURL from "discourse/lib/url"; -import { formatUsername } from "discourse/lib/utilities"; +import { clipboardCopy, formatUsername } from "discourse/lib/utilities"; import DecoratorHelper from "discourse/widgets/decorator-helper"; import hbs from "discourse/widgets/hbs-compiler"; import PostCooked from "discourse/widgets/post-cooked"; import { postTransformCallbacks } from "discourse/widgets/post-stream"; import RawHtml from "discourse/widgets/raw-html"; import { applyDecorators, createWidget } from "discourse/widgets/widget"; +import { isTesting } from "discourse-common/config/environment"; import { avatarUrl, translateSize } from "discourse-common/lib/avatar-utils"; import getURL, { getURLWithCDN } from "discourse-common/lib/get-url"; import { iconNode } from "discourse-common/lib/icon-library"; @@ -642,6 +647,38 @@ createWidget("post-contents", { }); }, + copyLink() { + // Copying the link to clipboard on mobile doesn't make sense. + if (this.site.mobileView) { + return this.share(); + } + + const post = this.findAncestorModel(); + const postUrl = post.shareUrl; + const postId = post.id; + + // Do nothing if the user just copied the link. + if (recentlyCopiedPostLink(postId)) { + return; + } + + const shareUrl = new URL(postUrl, window.origin).toString(); + + // Can't use clipboard in JS tests. + if (isTesting()) { + return showCopyPostLinkAlert(postId); + } + + clipboardCopy(shareUrl) + .then(() => { + showCopyPostLinkAlert(postId); + }) + .catch(() => { + // If the clipboard copy fails for some reason, may as well show the old modal. + this.share(); + }); + }, + init() { this.postContentsDestroyCallbacks = []; }, diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index 04ccbd09908..2e58940e0e9 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -25,6 +25,9 @@ import I18n from "discourse-i18n"; acceptance("Topic", function (needs) { needs.user(); + needs.settings({ + post_menu: "read|like|share|flag|edit|bookmark|delete|admin|reply|copyLink", + }); needs.pretender((server, helper) => { server.get("/c/2/visible_groups.json", () => helper.response(200, { @@ -87,6 +90,16 @@ acceptance("Topic", function (needs) { assert.ok(exists(".share-topic-modal"), "it shows the share modal"); }); + test("Copy Link Button", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:first-child button.post-action-menu__copy-link"); + + assert.ok( + exists(".post-action-menu__copy-link-checkmark"), + "it shows the Link Copied! message" + ); + }); + test("Showing and hiding the edit controls", async function (assert) { await visit("/t/internationalization-localization/280"); diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js index a0e6dc11cd3..98400db128b 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js @@ -192,7 +192,7 @@ module("Integration | Component | Widget | post", function (hooks) { assert.ok(!exists(".who-liked a.trigger-user-card")); }); - test(`like count with no likes`, async function (assert) { + test("like count with no likes", async function (assert) { this.set("args", { likeCount: 0 }); await render( @@ -203,6 +203,7 @@ module("Integration | Component | Widget | post", function (hooks) { }); test("share button", async function (assert) { + this.siteSettings.post_menu += "|share"; this.set("args", { shareUrl: "http://share-me.example.com" }); await render(hbs``); @@ -210,6 +211,17 @@ module("Integration | Component | Widget | post", function (hooks) { assert.ok(exists(".actions button.share"), "it renders a share button"); }); + test("copy link button", async function (assert) { + this.set("args", { shareUrl: "http://share-me.example.com" }); + + await render(hbs``); + + assert.ok( + exists(".actions button.post-action-menu__copy-link"), + "it renders a copy link button" + ); + }); + test("liking", async function (assert) { const args = { showLike: true, canToggleLike: true, id: 5 }; this.set("args", args); diff --git a/app/assets/stylesheets/desktop/_index.scss b/app/assets/stylesheets/desktop/_index.scss index 5ceece86036..48acb73ea54 100644 --- a/app/assets/stylesheets/desktop/_index.scss +++ b/app/assets/stylesheets/desktop/_index.scss @@ -12,6 +12,7 @@ @import "modal"; @import "topic-list"; @import "topic-post"; +@import "post-action-menu"; @import "topic"; @import "upload"; @import "user"; diff --git a/app/assets/stylesheets/desktop/post-action-menu.scss b/app/assets/stylesheets/desktop/post-action-menu.scss new file mode 100644 index 00000000000..eff8494b57e --- /dev/null +++ b/app/assets/stylesheets/desktop/post-action-menu.scss @@ -0,0 +1,76 @@ +@keyframes slide { + 0% { + } + 100% { + transform: translateY(100%) translateX(-50%); + opacity: 0; + } +} + +.post-link-copied-alert { + position: absolute; + top: -1.5rem; + left: 50%; + transform: translateX(-50%); + color: var(--success); + padding: 0.25rem 0.5rem; + white-space: nowrap; + font-size: var(--font-down-2); + opacity: 1; + transition: opacity 0.5s ease-in-out; + z-index: z("modal", "popover"); + &.-success { + color: var(--success); + } + + &.-fail { + color: var(--danger); + } + + &.slide-out { + animation: slide 1s cubic-bezier(0, 0, 0, 2) forwards; + } +} + +@keyframes draw { + to { + stroke-dashoffset: 0; + } +} + +.post-action-menu { + &__copy-link { + position: relative; + height: 100%; + + &.is-copied, + &.is-copied:hover { + .d-icon-d-post-share { + color: var(--success); + } + } + } + &__copy-link-checkmark { + position: absolute; + bottom: 0; + right: 0; + width: 20px; + height: 20px; + display: block; + stroke: #2ecc71; + opacity: 0; + transition: opacity 0.5s ease-in-out; + + &.is-visible { + opacity: 1; + } + + path { + stroke: var(--success); + stroke-width: 4; + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: draw 1s forwards; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 60689bd180a..c9a46f4cf90 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3553,6 +3553,8 @@ en: delete: "delete this post" undelete: "undelete this post" share: "share a link to this post" + copy_title: "copy a link to this post to clipboard" + link_copied: "Link copied!" more: "More" delete_replies: confirm: "Do you also want to delete the replies to this post?" diff --git a/config/site_settings.yml b/config/site_settings.yml index 8320a50952c..b9b59b83670 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -193,15 +193,16 @@ basic: client: true type: list list_type: simple - default: "read|like|share|flag|edit|bookmark|delete|admin|reply" + default: "read|like|copyLink|flag|edit|bookmark|delete|admin|reply" allow_any: false choices: - read + - copyLink + - share - like - edit - flag - delete - - share - bookmark - admin - reply