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
|
||||
// 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.
|
||||
function canModify(klass, type, resolverName, changes) {
|
||||
|
@ -594,7 +594,33 @@ class PluginApi {
|
|||
* 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) {
|
||||
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 DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
|
||||
import { formattedReminderTime } from "discourse/lib/bookmark";
|
||||
import { recentlyCopied, showAlert } from "discourse/lib/post-action-feedback";
|
||||
import {
|
||||
NO_REMINDER_ICON,
|
||||
WITH_REMINDER_ICON,
|
||||
|
@ -653,8 +654,36 @@ export default createWidget("post-menu", {
|
|||
if (buttonAttrs) {
|
||||
const { position, beforeButton, afterButton } = buttonAttrs;
|
||||
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 = [];
|
||||
if (beforeButton) {
|
||||
|
|
|
@ -4,11 +4,8 @@ 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 postActionFeedback from "discourse/lib/post-action-feedback";
|
||||
import { nativeShare } from "discourse/lib/pwa-utils";
|
||||
import {
|
||||
prioritizeNameFallback,
|
||||
|
@ -654,29 +651,22 @@ createWidget("post-contents", {
|
|||
}
|
||||
|
||||
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();
|
||||
let actionCallback = () => clipboardCopy(post.shareUrl);
|
||||
|
||||
// Can't use clipboard in JS tests.
|
||||
if (isTesting()) {
|
||||
return showCopyPostLinkAlert(postId);
|
||||
actionCallback = () => {};
|
||||
}
|
||||
|
||||
clipboardCopy(shareUrl)
|
||||
.then(() => {
|
||||
showCopyPostLinkAlert(postId);
|
||||
})
|
||||
.catch(() => {
|
||||
// If the clipboard copy fails for some reason, may as well show the old modal.
|
||||
this.share();
|
||||
});
|
||||
postActionFeedback({
|
||||
postId,
|
||||
actionClass: "post-action-menu__copy-link",
|
||||
messageKey: "post.controls.link_copied",
|
||||
actionCallback: () => actionCallback,
|
||||
errorCallback: () => this.share(),
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
|
|
|
@ -330,11 +330,17 @@ export default class Widget {
|
|||
|
||||
const view = this._findView();
|
||||
if (view) {
|
||||
const method = view.get(name);
|
||||
if (!method) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${name} not found`);
|
||||
return;
|
||||
let method;
|
||||
|
||||
if (typeof name === "function") {
|
||||
method = name;
|
||||
} else {
|
||||
method = view.get(name);
|
||||
if (!method) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${name} not found`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 { module, test } from "qunit";
|
||||
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) {
|
||||
this.set("args", { canCreatePost: true, canRemoveReply: true });
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.post-link-copied-alert {
|
||||
.post-action-feedback-alert {
|
||||
position: absolute;
|
||||
top: -1.5rem;
|
||||
left: 50%;
|
||||
|
@ -38,8 +38,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.post-action-menu {
|
||||
&__copy-link {
|
||||
.post-menu-area {
|
||||
.post-action-feedback-button {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
|
@ -50,14 +50,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
&__copy-link-checkmark {
|
||||
.post-action-feedback-svg {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
stroke: #2ecc71;
|
||||
stroke: var(--success);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
|
||||
|
|
Loading…
Reference in New Issue