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.
This commit is contained in:
Martin Brennan 2023-12-08 11:45:49 +10:00 committed by GitHub
parent 5b91dc1844
commit d5fe9b4f8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 220 additions and 5 deletions

View File

@ -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;

View File

@ -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 = `
<svg class="post-action-menu__copy-link-checkmark is-visible" id="copy_post_svg_postId_${postId}" xmlns="${SVG_NAMESPACE}" viewBox="0 0 52 52">
<path class="checkmark__check" fill="none" d="M13 26 l10 10 20 -20"/>
</svg>
`;
return svgElement;
}
function removeElement(element) {
element?.parentNode?.removeChild(element);
}

View File

@ -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",
};
});

View File

@ -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 = [];
},

View File

@ -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");

View File

@ -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`<MountWidget @widget="post" @args={{this.args}} />`);
@ -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`<MountWidget @widget="post" @args={{this.args}} />`);
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);

View File

@ -12,6 +12,7 @@
@import "modal";
@import "topic-list";
@import "topic-post";
@import "post-action-menu";
@import "topic";
@import "upload";
@import "user";

View File

@ -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;
}
}
}

View File

@ -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?"

View File

@ -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