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