Skip to content

Commit

Permalink
DEV: API allow post actions to optionally provide visual feedback
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
SamSaffron committed Dec 29, 2023
1 parent f5380bb commit c6cb319
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 97 deletions.
64 changes: 0 additions & 64 deletions app/assets/javascripts/discourse/app/lib/copy-post-link.js

This file was deleted.

28 changes: 27 additions & 1 deletion app/assets/javascripts/discourse/app/lib/plugin-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
90 changes: 90 additions & 0 deletions app/assets/javascripts/discourse/app/lib/post-action-feedback.js
Original file line number Diff line number Diff line change
@@ -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);
}
31 changes: 30 additions & 1 deletion app/assets/javascripts/discourse/app/widgets/post-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -653,8 +654,36 @@ export default createWidget("post-menu", {
if (buttonAttrs) {
const { position, beforeButton, afterButton } = buttonAttrs;
delete buttonAttrs.position;
let button;

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

let button = this.attach(this.settings.buttonType, buttonAttrs);
button = this.attach(this.settings.buttonType, buttonAttrs);

const content = [];
if (beforeButton) {
Expand Down
30 changes: 10 additions & 20 deletions app/assets/javascripts/discourse/app/widgets/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
16 changes: 11 additions & 5 deletions app/assets/javascripts/discourse/app/widgets/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });

Expand Down

0 comments on commit c6cb319

Please sign in to comment.