FEATURE: Add user tips for post and topic features (#18964)

* DEV: Add utility to hide all user tips

* DEV: Add UserTip Glimmer component

* DEV: Add tests for existing user tips

* FEATURE: Add user tip for post menu

* FEATURE: Add user tip for topic notification level

* FEATURE: Add user tip for suggested topics

* FEATURE: Hide new popups for existing users
This commit is contained in:
Bianca Nenciu 2022-11-15 17:36:08 +02:00 committed by GitHub
parent f90a8438e9
commit ac272c041e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 240 additions and 4 deletions

View File

@ -2,6 +2,7 @@ import { alias, or } from "@ember/object/computed";
import { computed } from "@ember/object"; import { computed } from "@ember/object";
import Component from "@ember/component"; import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { NotificationLevels } from "discourse/lib/notification-levels";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button"; import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import { getTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown"; import { getTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
@ -46,6 +47,11 @@ export default Component.extend({
return !isPM || this.canSendPms; return !isPM || this.canSendPms;
}, },
@discourseComputed("topic.details.notification_level")
showNotificationUserTip(notificationLevel) {
return notificationLevel >= NotificationLevels.TRACKING;
},
canSendPms: alias("currentUser.can_send_private_messages"), canSendPms: alias("currentUser.can_send_private_messages"),
canInviteTo: alias("topic.details.can_invite_to"), canInviteTo: alias("topic.details.can_invite_to"),

View File

@ -0,0 +1 @@
<span {{did-insert this.showUserTip}}></span>

View File

@ -0,0 +1,35 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { hideUserTip } from "discourse/lib/user-tips";
import I18n from "I18n";
export default class UserTip extends Component {
@service currentUser;
@action
showUserTip(element) {
if (!this.currentUser) {
return;
}
const { id, selector, content, placement } = this.args;
this.currentUser.showUserTip({
id,
titleText: I18n.t(`user_tips.${id}.title`),
contentText: content || I18n.t(`user_tips.${id}.content`),
reference: selector
? element.parentElement.querySelector(selector) || element.parentElement
: element,
appendTo: element.parentElement,
placement: placement || "top",
});
}
willDestroy() {
hideUserTip(this.args.id);
}
}

View File

@ -611,6 +611,10 @@ export default Controller.extend(bufferedProperty("model"), {
// Post related methods // Post related methods
replyToPost(post) { replyToPost(post) {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
const composerController = this.composer; const composerController = this.composer;
const topic = post ? post.get("topic") : this.model; const topic = post ? post.get("topic") : this.model;
const quoteState = this.quoteState; const quoteState = this.quoteState;

View File

@ -30,6 +30,7 @@ export function showUserTip(options) {
arrow: iconHTML("tippy-rounded-arrow"), arrow: iconHTML("tippy-rounded-arrow"),
placement: options.placement, placement: options.placement,
appendTo: options.appendTo,
// It often happens for the reference element to be rerendered. In this // It often happens for the reference element to be rerendered. In this
// case, tippy must be rerendered too. Having an animation means that the // case, tippy must be rerendered too. Having an animation means that the
@ -77,6 +78,15 @@ export function hideUserTip(userTipId) {
instance.destroy(); instance.destroy();
} }
delete instances[userTipId]; delete instances[userTipId];
const index = queue.findIndex((userTip) => userTip.id === userTipId);
if (index > -1) {
queue.splice(index, 1);
}
}
export function hideAllUserTips() {
Object.keys(instances).forEach(hideUserTip);
} }
function addToQueue(options) { function addToQueue(options) {

View File

@ -44,6 +44,7 @@ import { cancel } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import { import {
hideAllUserTips,
hideUserTip, hideUserTip,
showNextUserTip, showNextUserTip,
showUserTip, showUserTip,
@ -1101,8 +1102,10 @@ const User = RestModel.extend({
} }
if (!userTips[options.id]) { if (!userTips[options.id]) {
// eslint-disable-next-line no-console if (!isTesting()) {
console.warn("Cannot show user tip with type =", options.id); // eslint-disable-next-line no-console
console.warn("Cannot show user tip with type =", options.id);
}
return; return;
} }
@ -1142,7 +1145,7 @@ const User = RestModel.extend({
seenUserTips.push(userTips[userTipId]); seenUserTips.push(userTips[userTipId]);
} }
} else { } else {
Object.keys(userTips).forEach(hideUserTip); hideAllUserTips();
seenUserTips = [-1]; seenUserTips = [-1];
} }

View File

@ -1,4 +1,6 @@
<div id="suggested-topics" class="suggested-topics" role="complementary" aria-labelledby="suggested-topics-title"> <div id="suggested-topics" class="suggested-topics" role="complementary" aria-labelledby="suggested-topics-title">
<UserTip @id="suggested_topics" />
<h3 id="suggested-topics-title" class="suggested-topics-title"> <h3 id="suggested-topics-title" class="suggested-topics-title">
{{i18n this.suggestedTitleLabel}} {{i18n this.suggestedTitleLabel}}
</h3> </h3>

View File

@ -29,6 +29,10 @@
<PinnedButton @pinned={{this.topic.pinned}} @topic={{this.topic}} /> <PinnedButton @pinned={{this.topic.pinned}} @topic={{this.topic}} />
{{#if this.showNotificationsButton}} {{#if this.showNotificationsButton}}
{{#if this.showNotificationUserTip}}
<UserTip @id="topic_notification_levels" @selector=".notifications-button" />
{{/if}}
<TopicNotificationsButton @notificationLevel={{this.topic.details.notification_level}} @topic={{this.topic}} /> <TopicNotificationsButton @notificationLevel={{this.topic.details.notification_level}} @topic={{this.topic}} />
{{/if}} {{/if}}

View File

@ -211,8 +211,9 @@ createWidget("header-notifications", {
contentText: I18n.t("user_tips.first_notification.content"), contentText: I18n.t("user_tips.first_notification.content"),
reference: document reference: document
.querySelector(".badge-notification") .querySelector(".d-header .badge-notification")
?.parentElement?.querySelector(".avatar"), ?.parentElement?.querySelector(".avatar"),
appendTo: document.querySelector(".d-header .panel"),
placement: "bottom-end", placement: "bottom-end",
}); });

View File

@ -711,6 +711,10 @@ export default createWidget("post-menu", {
}, },
showMoreActions() { showMoreActions() {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
this.state.collapsed = false; this.state.collapsed = false;
const likesPromise = !this.state.likedUsers.length const likesPromise = !this.state.likedUsers.length
? this.getWhoLiked() ? this.getWhoLiked()
@ -730,6 +734,8 @@ export default createWidget("post-menu", {
keyValueStore && keyValueStore &&
keyValueStore.set({ key: "likedPostId", value: attrs.id }); keyValueStore.set({ key: "likedPostId", value: attrs.id });
return this.sendWidgetAction("showLogin"); return this.sendWidgetAction("showLogin");
} else {
this.currentUser.hideUserTipForever("post_menu");
} }
if (this.capabilities.canVibrate && !isTesting()) { if (this.capabilities.canVibrate && !isTesting()) {

View File

@ -25,6 +25,7 @@ import { transformBasicPost } from "discourse/lib/transform-post";
import autoGroupFlairForUser from "discourse/lib/avatar-flair"; import autoGroupFlairForUser from "discourse/lib/avatar-flair";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { nativeShare } from "discourse/lib/pwa-utils"; import { nativeShare } from "discourse/lib/pwa-utils";
import { hideUserTip } from "discourse/lib/user-tips";
function transformWithCallbacks(post) { function transformWithCallbacks(post) {
let transformed = transformBasicPost(post); let transformed = transformBasicPost(post);
@ -593,6 +594,10 @@ createWidget("post-contents", {
}, },
share() { share() {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
const post = this.findAncestorModel(); const post = this.findAncestorModel();
nativeShare(this.capabilities, { url: post.shareUrl }).catch(() => { nativeShare(this.capabilities, { url: post.shareUrl }).catch(() => {
const topic = post.topic; const topic = post.topic;
@ -928,4 +933,34 @@ export default createWidget("post", {
kvs.set({ key: "lastWarnedLikes", value: Date.now() }); kvs.set({ key: "lastWarnedLikes", value: Date.now() });
} }
}, },
didRenderWidget() {
if (!this.currentUser || !this.siteSettings.enable_user_tips) {
return;
}
const reference = document.querySelector(
".post-controls .actions .show-more-actions"
);
this.currentUser.showUserTip({
id: "post_menu",
titleText: I18n.t("user_tips.post_menu.title"),
contentText: I18n.t("user_tips.post_menu.content"),
reference,
appendTo: reference?.closest(".post-controls"),
placement: "top",
});
},
destroy() {
hideUserTip("post_menu");
},
willRerenderWidget() {
hideUserTip("post_menu");
},
}); });

View File

@ -612,6 +612,7 @@ export default createWidget("topic-timeline", {
contentText: I18n.t("user_tips.topic_timeline.content"), contentText: I18n.t("user_tips.topic_timeline.content"),
reference: document.querySelector("div.timeline-scrollarea-wrapper"), reference: document.querySelector("div.timeline-scrollarea-wrapper"),
appendTo: document.querySelector("div.topic-timeline"),
placement: "left", placement: "left",
}); });

View File

@ -0,0 +1,96 @@
import { visit } from "@ember/test-helpers";
import { hideAllUserTips } from "discourse/lib/user-tips";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
import { test } from "qunit";
acceptance("User Tips - first_notification", function (needs) {
needs.user({ unread_high_priority_notifications: 1 });
needs.site({ user_tips: { first_notification: 1 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows first notification user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.first_notification.title")
);
});
});
acceptance("User Tips - topic_timeline", function (needs) {
needs.user();
needs.site({ user_tips: { topic_timeline: 2 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows topic timeline user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.topic_timeline.title")
);
});
});
acceptance("User Tips - post_menu", function (needs) {
needs.user();
needs.site({ user_tips: { post_menu: 3 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.post_menu.title")
);
});
});
acceptance("User Tips - topic_notification_levels", function (needs) {
needs.user();
needs.site({ user_tips: { topic_notification_levels: 4 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.topic_notification_levels.title")
);
});
});
acceptance("User Tips - suggested_topics", function (needs) {
needs.user();
needs.site({ user_tips: { suggested_topics: 5 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.suggested_topics.title")
);
});
});

View File

@ -23,6 +23,12 @@
left: 11px; left: 11px;
} }
.tippy-box[data-theme~="user-tips"][data-placement^="top"]
> .tippy-svg-arrow
> svg {
top: 11px;
}
.tippy-box[data-theme~="user-tips"][data-placement^="bottom"] .tippy-box[data-theme~="user-tips"][data-placement^="bottom"]
> .tippy-svg-arrow > .tippy-svg-arrow
> svg { > svg {

View File

@ -288,6 +288,9 @@ class User < ActiveRecord::Base
@user_tips ||= Enum.new( @user_tips ||= Enum.new(
first_notification: 1, first_notification: 1,
topic_timeline: 2, topic_timeline: 2,
post_menu: 3,
topic_notification_levels: 4,
suggested_topics: 5,
) )
end end

View File

@ -1838,6 +1838,18 @@ en:
title: "Topic timeline" title: "Topic timeline"
content: "Scroll quickly through a post using the topic timeline." content: "Scroll quickly through a post using the topic timeline."
post_menu:
title: "Post menu"
content: "See how else you can interact with the post by clicking the three dots!"
topic_notification_levels:
title: "You are now following this topic"
content: "Look for this bell to adjust your notification preferences for specific topics or whole categories."
suggested_topics:
title: "Keep reading!"
content: "Here are some topics we think you might like to read next."
loading: "Loading..." loading: "Loading..."
errors: errors:
prev_page: "while trying to load" prev_page: "while trying to load"

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class HideUserTips3To5ForExistingUsers < ActiveRecord::Migration[7.0]
def up
execute "UPDATE user_options SET seen_popups = seen_popups || '{3, 4, 5}'"
end
def down
execute "UPDATE user_options SET seen_popups = array_remove(array_remove(array_remove(seen_popups, 3), 4), 5)"
end
end