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:
parent
f90a8438e9
commit
ac272c041e
|
@ -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"),
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<span {{did-insert this.showUserTip}}></span>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
@ -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()) {
|
||||||
|
|
|
@ -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");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
@ -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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue