FEATURE: Implement new onboarding popups (#18362)
This commit introduces a new framework for building user tutorials as popups using the Tippy JS library. Currently, the new framework is used to replace the old notification spotlight and tips and show a new one related to the topic timeline. All popups follow the same structure and have a title, a description and two buttons for either dismissing just the current tip or all of them at once. The state of all seen popups is stored in a user option. Updating skip_new_user_tips will automatically update the list of seen popups accordingly.
This commit is contained in:
@@ -71,6 +71,7 @@ export default Component.extend({
|
||||
this._connected.forEach((v) => v.destroy());
|
||||
this._connected.length = 0;
|
||||
|
||||
traverseCustomWidgets(this._tree, (w) => w.destroy());
|
||||
this._rootNode = patch(this._rootNode, diff(this._tree, null));
|
||||
this._tree = null;
|
||||
},
|
||||
|
||||
@@ -251,39 +251,41 @@ const SiteHeaderComponent = MountWidget.extend(
|
||||
this.currentUser.on("status-changed", this, "queueRerender");
|
||||
}
|
||||
|
||||
if (
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification")
|
||||
) {
|
||||
document.body.classList.add("unread-first-notification");
|
||||
}
|
||||
if (!this.siteSettings.enable_onboarding_popups) {
|
||||
if (
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification")
|
||||
) {
|
||||
document.body.classList.add("unread-first-notification");
|
||||
}
|
||||
|
||||
// Allow first notification to be dismissed on a click anywhere
|
||||
if (
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification") &&
|
||||
!this.get("currentUser.enforcedSecondFactor")
|
||||
) {
|
||||
this._dismissFirstNotification = (e) => {
|
||||
if (document.body.classList.contains("unread-first-notification")) {
|
||||
document.body.classList.remove("unread-first-notification");
|
||||
}
|
||||
if (
|
||||
!e.target.closest("#current-user") &&
|
||||
!e.target.closest(".ring-backdrop") &&
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification") &&
|
||||
!this.get("currentUser.enforcedSecondFactor")
|
||||
) {
|
||||
this.eventDispatched(
|
||||
"header:dismiss-first-notification-mask",
|
||||
"header"
|
||||
);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", this._dismissFirstNotification, {
|
||||
once: true,
|
||||
});
|
||||
// Allow first notification to be dismissed on a click anywhere
|
||||
if (
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification") &&
|
||||
!this.get("currentUser.enforcedSecondFactor")
|
||||
) {
|
||||
this._dismissFirstNotification = (e) => {
|
||||
if (document.body.classList.contains("unread-first-notification")) {
|
||||
document.body.classList.remove("unread-first-notification");
|
||||
}
|
||||
if (
|
||||
!e.target.closest("#current-user") &&
|
||||
!e.target.closest(".ring-backdrop") &&
|
||||
this.currentUser &&
|
||||
!this.get("currentUser.read_first_notification") &&
|
||||
!this.get("currentUser.enforcedSecondFactor")
|
||||
) {
|
||||
this.eventDispatched(
|
||||
"header:dismiss-first-notification-mask",
|
||||
"header"
|
||||
);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", this._dismissFirstNotification, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const header = document.querySelector("header.d-header");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
||||
import I18n from "I18n";
|
||||
import { escape } from "pretty-text/sanitizer";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
const instances = {};
|
||||
const queue = [];
|
||||
|
||||
export function showPopup(options) {
|
||||
hidePopup(options.id);
|
||||
|
||||
if (!options.reference) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(instances).length > 0) {
|
||||
return addToQueue(options);
|
||||
}
|
||||
|
||||
instances[options.id] = tippy(options.reference, {
|
||||
// Tippy must be displayed as soon as possible and not be hidden unless
|
||||
// the user clicks on one of the two buttons.
|
||||
showOnCreate: true,
|
||||
hideOnClick: false,
|
||||
trigger: "manual",
|
||||
|
||||
// It must be interactive to make buttons work.
|
||||
interactive: true,
|
||||
|
||||
arrow: iconHTML("tippy-rounded-arrow"),
|
||||
placement: options.placement,
|
||||
|
||||
// It often happens for the reference element to be rerendered. In this
|
||||
// case, tippy must be rerendered too. Having an animation means that the
|
||||
// animation will replay over and over again.
|
||||
animation: false,
|
||||
|
||||
// The `content` property below is HTML.
|
||||
allowHTML: true,
|
||||
|
||||
content: `
|
||||
<div class='onboarding-popup-container'>
|
||||
<div class='onboarding-popup-title'>${escape(options.titleText)}</div>
|
||||
<div class='onboarding-popup-content'>${escape(
|
||||
options.contentText
|
||||
)}</div>
|
||||
<div class='onboarding-popup-buttons'>
|
||||
<button class="btn btn-primary btn-dismiss">${escape(
|
||||
options.primaryBtnText || I18n.t("popup.primary")
|
||||
)}</button>
|
||||
<button class="btn btn-flat btn-text btn-dismiss-all">${escape(
|
||||
options.secondaryBtnText || I18n.t("popup.secondary")
|
||||
)}</button>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
onCreate(instance) {
|
||||
instance.popper
|
||||
.querySelector(".btn-dismiss")
|
||||
.addEventListener("click", (event) => {
|
||||
options.onDismiss();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
instance.popper
|
||||
.querySelector(".btn-dismiss-all")
|
||||
.addEventListener("click", (event) => {
|
||||
options.onDismissAll();
|
||||
event.preventDefault();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function hidePopup(popupId) {
|
||||
const instance = instances[popupId];
|
||||
if (instance && !instance.state.isDestroyed) {
|
||||
instance.destroy();
|
||||
}
|
||||
delete instances[popupId];
|
||||
}
|
||||
|
||||
function addToQueue(options) {
|
||||
for (let i = 0; i < queue.size; ++i) {
|
||||
if (queue[i].id === options.id) {
|
||||
queue[i] = options;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queue.push(options);
|
||||
}
|
||||
|
||||
export function showNextPopup() {
|
||||
const options = queue.shift();
|
||||
if (options) {
|
||||
showPopup(options);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import Evented from "@ember/object/evented";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { hidePopup, showNextPopup, showPopup } from "discourse/lib/popup";
|
||||
|
||||
export const SECOND_FACTOR_METHODS = {
|
||||
TOTP: 1,
|
||||
@@ -104,6 +105,7 @@ let userOptionFields = [
|
||||
"title_count_mode",
|
||||
"timezone",
|
||||
"skip_new_user_tips",
|
||||
"seen_popups",
|
||||
"default_calendar",
|
||||
"bookmark_auto_delete_preference",
|
||||
];
|
||||
@@ -441,7 +443,7 @@ const User = RestModel.extend({
|
||||
"external_links_in_new_tab",
|
||||
"dynamic_favicon"
|
||||
);
|
||||
User.current().setProperties(userProps);
|
||||
User.current()?.setProperties(userProps);
|
||||
this.setProperties(updatedState);
|
||||
return result;
|
||||
})
|
||||
@@ -1091,6 +1093,65 @@ const User = RestModel.extend({
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
showPopup(options) {
|
||||
const popupTypes = Site.currentProp("onboarding_popup_types");
|
||||
if (!popupTypes[options.id]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Cannot display popup with type =", options.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const seenPopups = this.seen_popups || [];
|
||||
if (seenPopups.includes(popupTypes[options.id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
showPopup({
|
||||
...options,
|
||||
onDismiss: () => this.hidePopupForever(options.id),
|
||||
onDismissAll: () => this.hidePopupForever(),
|
||||
});
|
||||
},
|
||||
|
||||
hidePopupForever(popupId) {
|
||||
// Empty popupId means all popups.
|
||||
const popupTypes = Site.currentProp("onboarding_popup_types");
|
||||
if (popupId && !popupTypes[popupId]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Cannot hide popup with type =", popupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide any shown popups.
|
||||
let seenPopups = this.seen_popups || [];
|
||||
if (popupId) {
|
||||
hidePopup(popupId);
|
||||
if (!seenPopups.includes(popupTypes[popupId])) {
|
||||
seenPopups.push(popupTypes[popupId]);
|
||||
}
|
||||
} else {
|
||||
Object.keys(popupTypes).forEach(hidePopup);
|
||||
seenPopups = Object.values(popupTypes);
|
||||
}
|
||||
|
||||
// Show next popup in queue.
|
||||
showNextPopup();
|
||||
|
||||
// Save seen popups on the server.
|
||||
if (!this.user_option) {
|
||||
this.set("user_option", {});
|
||||
}
|
||||
this.set("seen_popups", seenPopups);
|
||||
this.set("user_option.seen_popups", seenPopups);
|
||||
if (popupId) {
|
||||
return this.save(["seen_popups"]);
|
||||
} else {
|
||||
this.set("skip_new_user_tips", true);
|
||||
this.set("user_option.skip_new_user_tips", true);
|
||||
return this.save(["seen_popups", "skip_new_user_tips"]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
User.reopenClass(Singleton, {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
|
||||
import { logSearchLinkClick } from "discourse/lib/search";
|
||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { hidePopup } from "discourse/lib/popup";
|
||||
|
||||
let _extraHeaderIcons = [];
|
||||
|
||||
@@ -87,8 +88,13 @@ createWidget("header-notifications", {
|
||||
const count = unread + reviewables;
|
||||
if (count > 0) {
|
||||
if (this._shouldHighlightAvatar()) {
|
||||
this._addAvatarHighlight(contents);
|
||||
if (this.siteSettings.enable_onboarding_popups) {
|
||||
contents.push(h("span.ring"));
|
||||
} else {
|
||||
this._addAvatarHighlight(contents);
|
||||
}
|
||||
}
|
||||
|
||||
contents.push(
|
||||
this.attach("link", {
|
||||
action: attrs.action,
|
||||
@@ -118,7 +124,11 @@ createWidget("header-notifications", {
|
||||
const unreadHighPriority = user.unread_high_priority_notifications;
|
||||
if (!!unreadHighPriority) {
|
||||
if (this._shouldHighlightAvatar()) {
|
||||
this._addAvatarHighlight(contents);
|
||||
if (this.siteSettings.enable_onboarding_popups) {
|
||||
contents.push(h("span.ring"));
|
||||
} else {
|
||||
this._addAvatarHighlight(contents);
|
||||
}
|
||||
}
|
||||
|
||||
// add the counter for the unread high priority
|
||||
@@ -184,6 +194,37 @@ createWidget("header-notifications", {
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
didRenderWidget() {
|
||||
if (
|
||||
!this.currentUser ||
|
||||
!this.siteSettings.enable_onboarding_popups ||
|
||||
!this._shouldHighlightAvatar()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentUser.showPopup({
|
||||
id: "first_notification",
|
||||
|
||||
titleText: I18n.t("popup.first_notification.title"),
|
||||
contentText: I18n.t("popup.first_notification.content"),
|
||||
|
||||
reference: document
|
||||
.querySelector(".badge-notification")
|
||||
?.parentElement?.querySelector(".avatar"),
|
||||
|
||||
placement: "bottom-end",
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
hidePopup("first_notification");
|
||||
},
|
||||
|
||||
willRerenderWidget() {
|
||||
hidePopup("first_notification");
|
||||
},
|
||||
});
|
||||
|
||||
createWidget(
|
||||
|
||||
@@ -9,6 +9,7 @@ import discourseLater from "discourse-common/lib/later";
|
||||
import { relativeAge } from "discourse/lib/formatter";
|
||||
import renderTags from "discourse/lib/render-tags";
|
||||
import renderTopicFeaturedLink from "discourse/lib/render-topic-featured-link";
|
||||
import { hidePopup } from "discourse/lib/popup";
|
||||
|
||||
const SCROLLER_HEIGHT = 50;
|
||||
const LAST_READ_HEIGHT = 20;
|
||||
@@ -598,4 +599,29 @@ export default createWidget("topic-timeline", {
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
didRenderWidget() {
|
||||
if (!this.currentUser || !this.siteSettings.enable_onboarding_popups) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentUser.showPopup({
|
||||
id: "topic_timeline",
|
||||
|
||||
titleText: I18n.t("popup.topic_timeline.title"),
|
||||
contentText: I18n.t("popup.topic_timeline.content"),
|
||||
|
||||
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
|
||||
|
||||
placement: "left",
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
hidePopup("topic_timeline");
|
||||
},
|
||||
|
||||
willRerenderWidget() {
|
||||
hidePopup("topic_timeline");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ acceptance("Composer Actions", function (needs) {
|
||||
});
|
||||
needs.site({ can_tag_topics: true });
|
||||
needs.pretender((server, helper) => {
|
||||
server.put("/u/kris.json", () => helper.response({ user: {} }));
|
||||
const cardResponse = cloneJSON(userFixtures["/u/shade/card.json"]);
|
||||
server.get("/u/shade/card.json", () => helper.response(cardResponse));
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ acceptance("Composer", function (needs) {
|
||||
],
|
||||
});
|
||||
needs.pretender((server, helper) => {
|
||||
server.put("/u/kris.json", () => helper.response({ user: {} }));
|
||||
server.post("/uploads/lookup-urls", () => {
|
||||
return helper.response([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user