DEV: API allow post actions to optionally provide visual feedback
post action feedback is the mechanism in which we provide visual feedback to the user when a post action is clicked, in cases where the action is a background (hidden to user) for example: copying text to the clipboard Core uses this to share post links, but other plugins (for example: AI) use this to share post transcripts via the clipboard. This adds a proper plugin API to consume this functionality `addPostMenuButton` can provide a builder that specified a function as the action. This function will be called with an object that has both the current post and a method for showing feedback.
This commit is contained in:
parent
f5380bb890
commit
c6cb319671
|
@ -1,64 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -144,7 +144,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
|
||||||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
||||||
|
|
||||||
export const PLUGIN_API_VERSION = "1.21.0";
|
export const PLUGIN_API_VERSION = "1.22.0";
|
||||||
|
|
||||||
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||||
function canModify(klass, type, resolverName, changes) {
|
function canModify(klass, type, resolverName, changes) {
|
||||||
|
@ -594,7 +594,33 @@ class PluginApi {
|
||||||
* position: 'first' // can be `first`, `last` or `second-last-hidden`
|
* position: 'first' // can be `first`, `last` or `second-last-hidden`
|
||||||
* };
|
* };
|
||||||
* });
|
* });
|
||||||
|
*
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* action: may be a string or a function. If it is a string, a wiget action
|
||||||
|
* will be triggered. If it is function, the function will be called.
|
||||||
|
*
|
||||||
|
* function will recieve a single argument:
|
||||||
|
* {
|
||||||
|
* post:
|
||||||
|
* showFeedback:
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* showFeedback can be called to issue a visual feedback on button press.
|
||||||
|
* It gets a single argument with a localization key.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* api.addPostMenuButton('coffee', () => {
|
||||||
|
* return {
|
||||||
|
* action: ({ post, showFeedback }) => {
|
||||||
|
* drinkCoffee(post);
|
||||||
|
* showFeedback('discourse_plugin.coffee.drink');
|
||||||
|
* },
|
||||||
|
* icon: 'coffee',
|
||||||
|
* className: 'hot-coffee',
|
||||||
|
* }
|
||||||
|
* }
|
||||||
**/
|
**/
|
||||||
addPostMenuButton(name, callback) {
|
addPostMenuButton(name, callback) {
|
||||||
apiExtraButtons[name] = callback;
|
apiExtraButtons[name] = callback;
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { SVG_NAMESPACE } from "discourse-common/lib/icon-library";
|
||||||
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
|
export default function postActionFeedback({
|
||||||
|
postId,
|
||||||
|
actionClass,
|
||||||
|
messageKey,
|
||||||
|
actionCallback,
|
||||||
|
errorCallback,
|
||||||
|
}) {
|
||||||
|
if (recentlyCopied(postId, actionClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybePromise = actionCallback();
|
||||||
|
|
||||||
|
if (maybePromise && maybePromise.then) {
|
||||||
|
maybePromise
|
||||||
|
.then(() => {
|
||||||
|
showAlert(postId, actionClass, messageKey);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (errorCallback) {
|
||||||
|
errorCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showAlert(postId, actionClass, messageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentlyCopied(postId, actionClass) {
|
||||||
|
return document.querySelector(
|
||||||
|
`article[data-post-id='${postId}'] .${actionClass} .${actionClass}-checkmark`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showAlert(postId, actionClass, messageKey) {
|
||||||
|
const postSelector = `article[data-post-id='${postId}']`;
|
||||||
|
const actionBtn = document.querySelector(`${postSelector} .${actionClass}`);
|
||||||
|
|
||||||
|
actionBtn?.classList.add("post-action-feedback-button");
|
||||||
|
|
||||||
|
createAlert(I18n.t(messageKey), postId, actionBtn);
|
||||||
|
createCheckmark(actionBtn, actionClass, postId);
|
||||||
|
styleBtn(actionBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAlert(message, postId, actionBtn) {
|
||||||
|
if (!actionBtn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alertDiv = document.createElement("div");
|
||||||
|
alertDiv.className = "post-action-feedback-alert -success";
|
||||||
|
alertDiv.textContent = message;
|
||||||
|
|
||||||
|
actionBtn.appendChild(alertDiv);
|
||||||
|
|
||||||
|
setTimeout(() => alertDiv.classList.add("slide-out"), 1000);
|
||||||
|
setTimeout(() => removeElement(alertDiv), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCheckmark(btn, actionClass, postId) {
|
||||||
|
const svgId = `svg_${actionClass}_${postId}`;
|
||||||
|
const checkmark = makeCheckmarkSvg(postId, actionClass, svgId);
|
||||||
|
btn.appendChild(checkmark.content);
|
||||||
|
|
||||||
|
setTimeout(() => checkmark.classList.remove("is-visible"), 3000);
|
||||||
|
setTimeout(() => removeElement(document.getElementById(svgId)), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleBtn(btn) {
|
||||||
|
btn.classList.add("is-copied");
|
||||||
|
setTimeout(() => btn.classList.remove("is-copied"), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCheckmarkSvg(postId, actionClass, svgId) {
|
||||||
|
const svgElement = document.createElement("template");
|
||||||
|
svgElement.innerHTML = `
|
||||||
|
<svg class="${actionClass}-checkmark post-action-feedback-svg is-visible" id="${svgId}" 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);
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { h } from "virtual-dom";
|
||||||
import AdminPostMenu from "discourse/components/admin-post-menu";
|
import AdminPostMenu from "discourse/components/admin-post-menu";
|
||||||
import DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
|
import DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
|
||||||
import { formattedReminderTime } from "discourse/lib/bookmark";
|
import { formattedReminderTime } from "discourse/lib/bookmark";
|
||||||
|
import { recentlyCopied, showAlert } from "discourse/lib/post-action-feedback";
|
||||||
import {
|
import {
|
||||||
NO_REMINDER_ICON,
|
NO_REMINDER_ICON,
|
||||||
WITH_REMINDER_ICON,
|
WITH_REMINDER_ICON,
|
||||||
|
@ -653,8 +654,36 @@ export default createWidget("post-menu", {
|
||||||
if (buttonAttrs) {
|
if (buttonAttrs) {
|
||||||
const { position, beforeButton, afterButton } = buttonAttrs;
|
const { position, beforeButton, afterButton } = buttonAttrs;
|
||||||
delete buttonAttrs.position;
|
delete buttonAttrs.position;
|
||||||
|
let button;
|
||||||
|
|
||||||
let button = this.attach(this.settings.buttonType, buttonAttrs);
|
if (typeof buttonAttrs.action === "function") {
|
||||||
|
const original = buttonAttrs.action;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
buttonAttrs.action = async function (post) {
|
||||||
|
let showFeedback = null;
|
||||||
|
|
||||||
|
if (buttonAttrs.className) {
|
||||||
|
showFeedback = (messageKey) => {
|
||||||
|
showAlert(post.id, buttonAttrs.className, messageKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const postAttrs = {
|
||||||
|
post,
|
||||||
|
showFeedback,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!buttonAttrs.className ||
|
||||||
|
!recentlyCopied(post.id, buttonAttrs.actionClass)
|
||||||
|
) {
|
||||||
|
self.sendWidgetAction(original, postAttrs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
button = this.attach(this.settings.buttonType, buttonAttrs);
|
||||||
|
|
||||||
const content = [];
|
const content = [];
|
||||||
if (beforeButton) {
|
if (beforeButton) {
|
||||||
|
|
|
@ -4,11 +4,8 @@ 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 postActionFeedback from "discourse/lib/post-action-feedback";
|
||||||
import { nativeShare } from "discourse/lib/pwa-utils";
|
import { nativeShare } from "discourse/lib/pwa-utils";
|
||||||
import {
|
import {
|
||||||
prioritizeNameFallback,
|
prioritizeNameFallback,
|
||||||
|
@ -654,29 +651,22 @@ createWidget("post-contents", {
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = this.findAncestorModel();
|
const post = this.findAncestorModel();
|
||||||
const postUrl = post.shareUrl;
|
|
||||||
const postId = post.id;
|
const postId = post.id;
|
||||||
|
|
||||||
// Do nothing if the user just copied the link.
|
let actionCallback = () => clipboardCopy(post.shareUrl);
|
||||||
if (recentlyCopiedPostLink(postId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareUrl = new URL(postUrl, window.origin).toString();
|
|
||||||
|
|
||||||
// Can't use clipboard in JS tests.
|
// Can't use clipboard in JS tests.
|
||||||
if (isTesting()) {
|
if (isTesting()) {
|
||||||
return showCopyPostLinkAlert(postId);
|
actionCallback = () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
clipboardCopy(shareUrl)
|
postActionFeedback({
|
||||||
.then(() => {
|
postId,
|
||||||
showCopyPostLinkAlert(postId);
|
actionClass: "post-action-menu__copy-link",
|
||||||
})
|
messageKey: "post.controls.link_copied",
|
||||||
.catch(() => {
|
actionCallback: () => actionCallback,
|
||||||
// If the clipboard copy fails for some reason, may as well show the old modal.
|
errorCallback: () => this.share(),
|
||||||
this.share();
|
});
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
|
@ -330,11 +330,17 @@ export default class Widget {
|
||||||
|
|
||||||
const view = this._findView();
|
const view = this._findView();
|
||||||
if (view) {
|
if (view) {
|
||||||
const method = view.get(name);
|
let method;
|
||||||
if (!method) {
|
|
||||||
// eslint-disable-next-line no-console
|
if (typeof name === "function") {
|
||||||
console.warn(`${name} not found`);
|
method = name;
|
||||||
return;
|
} else {
|
||||||
|
method = view.get(name);
|
||||||
|
if (!method) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`${name} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof method === "string") {
|
if (typeof method === "string") {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { render } from "@ember/test-helpers";
|
import { click, render } from "@ember/test-helpers";
|
||||||
import { hbs } from "ember-cli-htmlbars";
|
import { hbs } from "ember-cli-htmlbars";
|
||||||
import { module, test } from "qunit";
|
import { module, test } from "qunit";
|
||||||
import { h } from "virtual-dom";
|
import { h } from "virtual-dom";
|
||||||
|
@ -38,6 +38,48 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("add extra button with feedback", async function (assert) {
|
||||||
|
this.set("args", {});
|
||||||
|
|
||||||
|
let testPost = null;
|
||||||
|
|
||||||
|
withPluginApi("0.14.0", (api) => {
|
||||||
|
api.addPostMenuButton("coffee", () => {
|
||||||
|
return {
|
||||||
|
action: ({ post, showFeedback }) => {
|
||||||
|
testPost = post;
|
||||||
|
showFeedback("coffee.drink");
|
||||||
|
},
|
||||||
|
icon: "coffee",
|
||||||
|
className: "hot-coffee",
|
||||||
|
title: "coffee.title",
|
||||||
|
position: "first",
|
||||||
|
actionParam: { id: 123 }, // hack for testing
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<article data-post-id="123">
|
||||||
|
<MountWidget @widget="post-menu" @args={{this.args}} />
|
||||||
|
</article>`);
|
||||||
|
|
||||||
|
await click(".hot-coffee");
|
||||||
|
|
||||||
|
assert.strictEqual(123, testPost.id, "callback was called with post");
|
||||||
|
assert.strictEqual(
|
||||||
|
count(".post-action-feedback-button"),
|
||||||
|
1,
|
||||||
|
"It renders feedback"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
count(".actions .extra-buttons .hot-coffee"),
|
||||||
|
1,
|
||||||
|
"It renders extra button"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("removes button based on callback", async function (assert) {
|
test("removes button based on callback", async function (assert) {
|
||||||
this.set("args", { canCreatePost: true, canRemoveReply: true });
|
this.set("args", { canCreatePost: true, canRemoveReply: true });
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-link-copied-alert {
|
.post-action-feedback-alert {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1.5rem;
|
top: -1.5rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
@ -38,8 +38,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-action-menu {
|
.post-menu-area {
|
||||||
&__copy-link {
|
.post-action-feedback-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
@ -50,14 +50,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&__copy-link-checkmark {
|
.post-action-feedback-svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
display: block;
|
display: block;
|
||||||
stroke: #2ecc71;
|
stroke: var(--success);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s ease-in-out;
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue