diff --git a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js
index e65a85aeb8e..5f91ecca8e2 100644
--- a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js
+++ b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js
@@ -2,6 +2,7 @@ import { alias, or } from "@ember/object/computed";
import { computed } from "@ember/object";
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
+import { NotificationLevels } from "discourse/lib/notification-levels";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import { getTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
@@ -46,6 +47,11 @@ export default Component.extend({
return !isPM || this.canSendPms;
},
+ @discourseComputed("topic.details.notification_level")
+ showNotificationUserTip(notificationLevel) {
+ return notificationLevel >= NotificationLevels.TRACKING;
+ },
+
canSendPms: alias("currentUser.can_send_private_messages"),
canInviteTo: alias("topic.details.can_invite_to"),
diff --git a/app/assets/javascripts/discourse/app/components/user-tip.hbs b/app/assets/javascripts/discourse/app/components/user-tip.hbs
new file mode 100644
index 00000000000..dc4b3e55d50
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-tip.hbs
@@ -0,0 +1 @@
+
diff --git a/app/assets/javascripts/discourse/app/components/user-tip.js b/app/assets/javascripts/discourse/app/components/user-tip.js
new file mode 100644
index 00000000000..d85656f898d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-tip.js
@@ -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);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js
index 7e473e321cd..56a5b6ba4b8 100644
--- a/app/assets/javascripts/discourse/app/controllers/topic.js
+++ b/app/assets/javascripts/discourse/app/controllers/topic.js
@@ -611,6 +611,10 @@ export default Controller.extend(bufferedProperty("model"), {
// Post related methods
replyToPost(post) {
+ if (this.currentUser) {
+ this.currentUser.hideUserTipForever("post_menu");
+ }
+
const composerController = this.composer;
const topic = post ? post.get("topic") : this.model;
const quoteState = this.quoteState;
diff --git a/app/assets/javascripts/discourse/app/lib/user-tips.js b/app/assets/javascripts/discourse/app/lib/user-tips.js
index 3eefac3bdea..df27654a90e 100644
--- a/app/assets/javascripts/discourse/app/lib/user-tips.js
+++ b/app/assets/javascripts/discourse/app/lib/user-tips.js
@@ -30,6 +30,7 @@ export function showUserTip(options) {
arrow: iconHTML("tippy-rounded-arrow"),
placement: options.placement,
+ appendTo: options.appendTo,
// It often happens for the reference element to be rerendered. In this
// case, tippy must be rerendered too. Having an animation means that the
@@ -77,6 +78,15 @@ export function hideUserTip(userTipId) {
instance.destroy();
}
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) {
diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js
index 89ae1f2fbd7..c12ea209f89 100644
--- a/app/assets/javascripts/discourse/app/models/user.js
+++ b/app/assets/javascripts/discourse/app/models/user.js
@@ -44,6 +44,7 @@ import { cancel } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
import { isTesting } from "discourse-common/config/environment";
import {
+ hideAllUserTips,
hideUserTip,
showNextUserTip,
showUserTip,
@@ -1101,8 +1102,10 @@ const User = RestModel.extend({
}
if (!userTips[options.id]) {
- // eslint-disable-next-line no-console
- console.warn("Cannot show user tip with type =", options.id);
+ if (!isTesting()) {
+ // eslint-disable-next-line no-console
+ console.warn("Cannot show user tip with type =", options.id);
+ }
return;
}
@@ -1142,7 +1145,7 @@ const User = RestModel.extend({
seenUserTips.push(userTips[userTipId]);
}
} else {
- Object.keys(userTips).forEach(hideUserTip);
+ hideAllUserTips();
seenUserTips = [-1];
}
diff --git a/app/assets/javascripts/discourse/app/templates/components/suggested-topics.hbs b/app/assets/javascripts/discourse/app/templates/components/suggested-topics.hbs
index 77ad43678b3..0725c3d6edc 100644
--- a/app/assets/javascripts/discourse/app/templates/components/suggested-topics.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/suggested-topics.hbs
@@ -1,4 +1,6 @@
+
+
{{i18n this.suggestedTitleLabel}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs b/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs
index 5df744c1df1..3899944af3f 100644
--- a/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs
@@ -29,6 +29,10 @@
{{#if this.showNotificationsButton}}
+ {{#if this.showNotificationUserTip}}
+
+ {{/if}}
+
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js
index daba9abda38..216f9445d29 100644
--- a/app/assets/javascripts/discourse/app/widgets/header.js
+++ b/app/assets/javascripts/discourse/app/widgets/header.js
@@ -211,8 +211,9 @@ createWidget("header-notifications", {
contentText: I18n.t("user_tips.first_notification.content"),
reference: document
- .querySelector(".badge-notification")
+ .querySelector(".d-header .badge-notification")
?.parentElement?.querySelector(".avatar"),
+ appendTo: document.querySelector(".d-header .panel"),
placement: "bottom-end",
});
diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js
index aa7df8b2235..acb62372224 100644
--- a/app/assets/javascripts/discourse/app/widgets/post-menu.js
+++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js
@@ -711,6 +711,10 @@ export default createWidget("post-menu", {
},
showMoreActions() {
+ if (this.currentUser) {
+ this.currentUser.hideUserTipForever("post_menu");
+ }
+
this.state.collapsed = false;
const likesPromise = !this.state.likedUsers.length
? this.getWhoLiked()
@@ -730,6 +734,8 @@ export default createWidget("post-menu", {
keyValueStore &&
keyValueStore.set({ key: "likedPostId", value: attrs.id });
return this.sendWidgetAction("showLogin");
+ } else {
+ this.currentUser.hideUserTipForever("post_menu");
}
if (this.capabilities.canVibrate && !isTesting()) {
diff --git a/app/assets/javascripts/discourse/app/widgets/post.js b/app/assets/javascripts/discourse/app/widgets/post.js
index 275b98c7c12..7ba5afe0b06 100644
--- a/app/assets/javascripts/discourse/app/widgets/post.js
+++ b/app/assets/javascripts/discourse/app/widgets/post.js
@@ -25,6 +25,7 @@ import { transformBasicPost } from "discourse/lib/transform-post";
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
import showModal from "discourse/lib/show-modal";
import { nativeShare } from "discourse/lib/pwa-utils";
+import { hideUserTip } from "discourse/lib/user-tips";
function transformWithCallbacks(post) {
let transformed = transformBasicPost(post);
@@ -593,6 +594,10 @@ createWidget("post-contents", {
},
share() {
+ if (this.currentUser) {
+ this.currentUser.hideUserTipForever("post_menu");
+ }
+
const post = this.findAncestorModel();
nativeShare(this.capabilities, { url: post.shareUrl }).catch(() => {
const topic = post.topic;
@@ -928,4 +933,34 @@ export default createWidget("post", {
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");
+ },
});
diff --git a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js
index 8d4566757e7..45a24e1ad2e 100644
--- a/app/assets/javascripts/discourse/app/widgets/topic-timeline.js
+++ b/app/assets/javascripts/discourse/app/widgets/topic-timeline.js
@@ -612,6 +612,7 @@ export default createWidget("topic-timeline", {
contentText: I18n.t("user_tips.topic_timeline.content"),
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
+ appendTo: document.querySelector("div.topic-timeline"),
placement: "left",
});
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js
new file mode 100644
index 00000000000..3ae693249ff
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-tips-test.js
@@ -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")
+ );
+ });
+});
diff --git a/app/assets/stylesheets/common/base/user-tips.scss b/app/assets/stylesheets/common/base/user-tips.scss
index a20bc51ccef..ae1f511825a 100644
--- a/app/assets/stylesheets/common/base/user-tips.scss
+++ b/app/assets/stylesheets/common/base/user-tips.scss
@@ -23,6 +23,12 @@
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-svg-arrow
> svg {
diff --git a/app/models/user.rb b/app/models/user.rb
index 59a881e6f48..4045830007c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -288,6 +288,9 @@ class User < ActiveRecord::Base
@user_tips ||= Enum.new(
first_notification: 1,
topic_timeline: 2,
+ post_menu: 3,
+ topic_notification_levels: 4,
+ suggested_topics: 5,
)
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 14efad7f912..709b79f8e2f 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1838,6 +1838,18 @@ en:
title: "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..."
errors:
prev_page: "while trying to load"
diff --git a/db/migrate/20221114215902_hide_user_tips_3_to_5_for_existing_users.rb b/db/migrate/20221114215902_hide_user_tips_3_to_5_for_existing_users.rb
new file mode 100644
index 00000000000..b4aa179946b
--- /dev/null
+++ b/db/migrate/20221114215902_hide_user_tips_3_to_5_for_existing_users.rb
@@ -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