mirror of
https://github.com/discourse/discourse.git
synced 2025-06-06 23:07:28 +08:00
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:
@ -5,7 +5,7 @@ import deprecated from "discourse-common/lib/deprecated";
|
|||||||
import escape from "discourse-common/lib/escape";
|
import escape from "discourse-common/lib/escape";
|
||||||
import I18n from "discourse-i18n";
|
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 _renderers = [];
|
||||||
|
|
||||||
let warnMissingIcons = true;
|
let warnMissingIcons = true;
|
||||||
|
64
app/assets/javascripts/discourse/app/lib/copy-post-link.js
Normal file
64
app/assets/javascripts/discourse/app/lib/copy-post-link.js
Normal 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);
|
||||||
|
}
|
@ -331,9 +331,18 @@ registerButton("replies", (attrs, state, siteSettings) => {
|
|||||||
registerButton("share", () => {
|
registerButton("share", () => {
|
||||||
return {
|
return {
|
||||||
action: "share",
|
action: "share",
|
||||||
|
icon: "d-post-share",
|
||||||
className: "share",
|
className: "share",
|
||||||
title: "post.controls.share",
|
title: "post.controls.share",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButton("copyLink", () => {
|
||||||
|
return {
|
||||||
|
action: "copyLink",
|
||||||
icon: "d-post-share",
|
icon: "d-post-share",
|
||||||
|
className: "post-action-menu__copy-link",
|
||||||
|
title: "post.controls.copy_title",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,6 +4,10 @@ import { h } from "virtual-dom";
|
|||||||
import ShareTopicModal from "discourse/components/modal/share-topic";
|
import ShareTopicModal from "discourse/components/modal/share-topic";
|
||||||
import { dateNode } from "discourse/helpers/node";
|
import { dateNode } from "discourse/helpers/node";
|
||||||
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
|
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
|
||||||
|
import {
|
||||||
|
recentlyCopiedPostLink,
|
||||||
|
showCopyPostLinkAlert,
|
||||||
|
} from "discourse/lib/copy-post-link";
|
||||||
import { relativeAgeMediumSpan } from "discourse/lib/formatter";
|
import { relativeAgeMediumSpan } from "discourse/lib/formatter";
|
||||||
import { nativeShare } from "discourse/lib/pwa-utils";
|
import { nativeShare } from "discourse/lib/pwa-utils";
|
||||||
import {
|
import {
|
||||||
@ -12,13 +16,14 @@ import {
|
|||||||
} from "discourse/lib/settings";
|
} from "discourse/lib/settings";
|
||||||
import { transformBasicPost } from "discourse/lib/transform-post";
|
import { transformBasicPost } from "discourse/lib/transform-post";
|
||||||
import DiscourseURL from "discourse/lib/url";
|
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 DecoratorHelper from "discourse/widgets/decorator-helper";
|
||||||
import hbs from "discourse/widgets/hbs-compiler";
|
import hbs from "discourse/widgets/hbs-compiler";
|
||||||
import PostCooked from "discourse/widgets/post-cooked";
|
import PostCooked from "discourse/widgets/post-cooked";
|
||||||
import { postTransformCallbacks } from "discourse/widgets/post-stream";
|
import { postTransformCallbacks } from "discourse/widgets/post-stream";
|
||||||
import RawHtml from "discourse/widgets/raw-html";
|
import RawHtml from "discourse/widgets/raw-html";
|
||||||
import { applyDecorators, createWidget } from "discourse/widgets/widget";
|
import { applyDecorators, createWidget } from "discourse/widgets/widget";
|
||||||
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
import { avatarUrl, translateSize } from "discourse-common/lib/avatar-utils";
|
import { avatarUrl, translateSize } from "discourse-common/lib/avatar-utils";
|
||||||
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
|
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
|
||||||
import { iconNode } from "discourse-common/lib/icon-library";
|
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() {
|
init() {
|
||||||
this.postContentsDestroyCallbacks = [];
|
this.postContentsDestroyCallbacks = [];
|
||||||
},
|
},
|
||||||
|
@ -25,6 +25,9 @@ import I18n from "discourse-i18n";
|
|||||||
|
|
||||||
acceptance("Topic", function (needs) {
|
acceptance("Topic", function (needs) {
|
||||||
needs.user();
|
needs.user();
|
||||||
|
needs.settings({
|
||||||
|
post_menu: "read|like|share|flag|edit|bookmark|delete|admin|reply|copyLink",
|
||||||
|
});
|
||||||
needs.pretender((server, helper) => {
|
needs.pretender((server, helper) => {
|
||||||
server.get("/c/2/visible_groups.json", () =>
|
server.get("/c/2/visible_groups.json", () =>
|
||||||
helper.response(200, {
|
helper.response(200, {
|
||||||
@ -87,6 +90,16 @@ acceptance("Topic", function (needs) {
|
|||||||
assert.ok(exists(".share-topic-modal"), "it shows the share modal");
|
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) {
|
test("Showing and hiding the edit controls", async function (assert) {
|
||||||
await visit("/t/internationalization-localization/280");
|
await visit("/t/internationalization-localization/280");
|
||||||
|
|
||||||
|
@ -192,7 +192,7 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||||||
assert.ok(!exists(".who-liked a.trigger-user-card"));
|
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 });
|
this.set("args", { likeCount: 0 });
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
@ -203,6 +203,7 @@ module("Integration | Component | Widget | post", function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("share button", async function (assert) {
|
test("share button", async function (assert) {
|
||||||
|
this.siteSettings.post_menu += "|share";
|
||||||
this.set("args", { shareUrl: "http://share-me.example.com" });
|
this.set("args", { shareUrl: "http://share-me.example.com" });
|
||||||
|
|
||||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
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");
|
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) {
|
test("liking", async function (assert) {
|
||||||
const args = { showLike: true, canToggleLike: true, id: 5 };
|
const args = { showLike: true, canToggleLike: true, id: 5 };
|
||||||
this.set("args", args);
|
this.set("args", args);
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
@import "modal";
|
@import "modal";
|
||||||
@import "topic-list";
|
@import "topic-list";
|
||||||
@import "topic-post";
|
@import "topic-post";
|
||||||
|
@import "post-action-menu";
|
||||||
@import "topic";
|
@import "topic";
|
||||||
@import "upload";
|
@import "upload";
|
||||||
@import "user";
|
@import "user";
|
||||||
|
76
app/assets/stylesheets/desktop/post-action-menu.scss
Normal file
76
app/assets/stylesheets/desktop/post-action-menu.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3553,6 +3553,8 @@ en:
|
|||||||
delete: "delete this post"
|
delete: "delete this post"
|
||||||
undelete: "undelete this post"
|
undelete: "undelete this post"
|
||||||
share: "share a link to 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"
|
more: "More"
|
||||||
delete_replies:
|
delete_replies:
|
||||||
confirm: "Do you also want to delete the replies to this post?"
|
confirm: "Do you also want to delete the replies to this post?"
|
||||||
|
@ -193,15 +193,16 @@ basic:
|
|||||||
client: true
|
client: true
|
||||||
type: list
|
type: list
|
||||||
list_type: simple
|
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
|
allow_any: false
|
||||||
choices:
|
choices:
|
||||||
- read
|
- read
|
||||||
|
- copyLink
|
||||||
|
- share
|
||||||
- like
|
- like
|
||||||
- edit
|
- edit
|
||||||
- flag
|
- flag
|
||||||
- delete
|
- delete
|
||||||
- share
|
|
||||||
- bookmark
|
- bookmark
|
||||||
- admin
|
- admin
|
||||||
- reply
|
- reply
|
||||||
|
Reference in New Issue
Block a user