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:
Sam 2023-12-29 15:59:43 +11:00 committed by GitHub
parent f5380bb890
commit c6cb319671
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 216 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,28 +651,21 @@ 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();
}); });
}, },

View File

@ -330,12 +330,18 @@ export default class Widget {
const view = this._findView(); const view = this._findView();
if (view) { if (view) {
const method = view.get(name); let method;
if (typeof name === "function") {
method = name;
} else {
method = view.get(name);
if (!method) { if (!method) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(`${name} not found`); console.warn(`${name} not found`);
return; return;
} }
}
if (typeof method === "string") { if (typeof method === "string") {
view[method](param); view[method](param);

View File

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

View File

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