diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js
index 2dddf0becfe..689108b01ae 100644
--- a/app/assets/javascripts/discourse/app/components/site-header.js
+++ b/app/assets/javascripts/discourse/app/components/site-header.js
@@ -8,7 +8,6 @@ import Docking from "discourse/mixins/docking";
import MountWidget from "discourse/components/mount-widget";
import ItsATrap from "@discourse/itsatrap";
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
-import { headerOffset } from "discourse/lib/offset-calculator";
import { observes } from "discourse-common/utils/decorators";
import { topicTitleDecorators } from "discourse/components/topic-title";
@@ -232,6 +231,9 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.on("header:show-topic", this, "setTopic");
this.appEvents.on("header:hide-topic", this, "setTopic");
+ if (this.currentUser?.redesigned_user_menu_enabled) {
+ this.appEvents.on("user-menu:rendered", this, "_animateMenu");
+ }
this.dispatch("notifications:changed", "user-notifications");
this.dispatch("header:keyboard-trigger", "header");
@@ -280,7 +282,40 @@ const SiteHeaderComponent = MountWidget.extend(
const header = document.querySelector("header.d-header");
this._itsatrap = new ItsATrap(header);
- this._itsatrap.bind(["right", "left"], (e) => {
+ const dirs = this.currentUser?.redesigned_user_menu_enabled
+ ? ["up", "down"]
+ : ["right", "left"];
+ this._itsatrap.bind(dirs, (e) => this._handleArrowKeysNav(e));
+ },
+
+ _handleArrowKeysNav(event) {
+ if (this.currentUser?.redesigned_user_menu_enabled) {
+ const activeTab = document.querySelector(
+ ".menu-tabs-container .btn.active"
+ );
+ if (activeTab) {
+ let activeTabNumber = Number(
+ document.activeElement.dataset.tabNumber ||
+ activeTab.dataset.tabNumber
+ );
+ const maxTabNumber =
+ document.querySelectorAll(".menu-tabs-container .btn").length - 1;
+ const isNext = event.key === "ArrowDown";
+ let nextTab = isNext ? activeTabNumber + 1 : activeTabNumber - 1;
+ if (isNext && nextTab > maxTabNumber) {
+ nextTab = 0;
+ }
+ if (!isNext && nextTab < 0) {
+ nextTab = maxTabNumber;
+ }
+ event.preventDefault();
+ document
+ .querySelector(
+ `.menu-tabs-container .btn[data-tab-number='${nextTab}']`
+ )
+ .focus();
+ }
+ } else {
const activeTab = document.querySelector(".glyphs .menu-link.active");
if (activeTab) {
@@ -290,11 +325,11 @@ const SiteHeaderComponent = MountWidget.extend(
}
this.appEvents.trigger("user-menu:navigation", {
- key: e.key,
+ key: event.key,
tabNumber: Number(focusedTab.dataset.tabNumber),
});
}
- });
+ }
},
_cleanDom() {
@@ -312,6 +347,9 @@ const SiteHeaderComponent = MountWidget.extend(
this.appEvents.off("header:show-topic", this, "setTopic");
this.appEvents.off("header:hide-topic", this, "setTopic");
this.appEvents.off("dom:clean", this, "_cleanDom");
+ if (this.currentUser?.redesigned_user_menu_enabled) {
+ this.appEvents.off("user-menu:rendered", this, "_animateMenu");
+ }
if (this.currentUser) {
this.currentUser.off("status-changed", this, "queueRerender");
@@ -339,7 +377,10 @@ const SiteHeaderComponent = MountWidget.extend(
cb(this._topic, headerTitle, "header-title")
);
}
+ this._animateMenu();
+ },
+ _animateMenu() {
const menuPanels = document.querySelectorAll(".menu-panel");
if (menuPanels.length === 0) {
if (this.site.mobileView) {
@@ -383,7 +424,7 @@ const SiteHeaderComponent = MountWidget.extend(
// We use a mutationObserver to check for style changes, so it's important
// we don't set it if it doesn't change. Same goes for the panelBody!
- if (viewMode === "drop-down") {
+ if (!this.site.mobileView) {
const buttonPanel = document.querySelectorAll("header ul.icons");
if (buttonPanel.length === 0) {
return;
@@ -395,23 +436,18 @@ const SiteHeaderComponent = MountWidget.extend(
panel.style.setProperty("top", "100%");
panel.style.setProperty("height", "auto");
}
-
- document.body.classList.add("drop-down-mode");
} else {
- if (this.site.mobileView) {
- headerCloak.style.display = "block";
- }
+ headerCloak.style.display = "block";
- const menuTop = this.site.mobileView ? headerTop() : headerOffset();
+ const menuTop = headerTop();
- const winHeightOffset = 16;
+ const winHeightOffset = this.currentUser?.redesigned_user_menu_enabled
+ ? 0
+ : 16;
let initialWinHeight = window.innerHeight;
const winHeight = initialWinHeight - winHeightOffset;
- let height;
- if (this.site.mobileView) {
- height = winHeight - menuTop;
- }
+ let height = winHeight - menuTop;
const isIPadApp = document.body.classList.contains("footer-nav-ipad"),
heightProp = isIPadApp ? "max-height" : "height",
@@ -434,10 +470,13 @@ const SiteHeaderComponent = MountWidget.extend(
headerCloak.style.top = `${menuTop}px`;
}
}
- document.body.classList.remove("drop-down-mode");
}
- panel.style.setProperty("width", `${width}px`);
+ // TODO: remove the if condition when redesigned_user_menu_enabled is
+ // removed
+ if (!panel.classList.contains("revamped")) {
+ panel.style.setProperty("width", `${width}px`);
+ }
if (this._animate) {
this._animateOpening(panel);
}
@@ -449,6 +488,7 @@ const SiteHeaderComponent = MountWidget.extend(
export default SiteHeaderComponent.extend({
classNames: ["d-header-wrap"],
+ classNameBindings: ["site.mobileView::drop-down-mode"],
init() {
this._super(...arguments);
diff --git a/app/assets/javascripts/discourse/app/components/user-menu-wrapper.js b/app/assets/javascripts/discourse/app/components/user-menu-wrapper.js
new file mode 100644
index 00000000000..c1d2b574dbc
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu-wrapper.js
@@ -0,0 +1,12 @@
+import Component from "@ember/component";
+import layout from "discourse/templates/components/user-menu-wrapper";
+
+export default Component.extend({
+ layout,
+ tagName: "",
+
+ didInsertElement() {
+ this._super(...arguments);
+ this.appEvents.trigger("user-menu:rendered");
+ },
+});
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/items-list-empty-state.hbs b/app/assets/javascripts/discourse/app/components/user-menu/items-list-empty-state.hbs
new file mode 100644
index 00000000000..d307388b7b1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/items-list-empty-state.hbs
@@ -0,0 +1,5 @@
+
+
+ {{i18n "user_menu.generic_no_items"}}
+
+
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/items-list.hbs b/app/assets/javascripts/discourse/app/components/user-menu/items-list.hbs
new file mode 100644
index 00000000000..9d6cbeaafc3
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/items-list.hbs
@@ -0,0 +1,31 @@
+{{#if this.loading}}
+
+{{else if this.items.length}}
+
+ {{#each this.items as |item|}}
+
+ {{/each}}
+
+
+ {{#if this.showAll}}
+
+ {{d-icon "chevron-down" aria-label=this.showAllTitle}}
+
+ {{/if}}
+ {{#if this.showDismiss}}
+
+ {{/if}}
+
+{{else}}
+
+{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/items-list.js b/app/assets/javascripts/discourse/app/components/user-menu/items-list.js
new file mode 100644
index 00000000000..77cf43a1cb3
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/items-list.js
@@ -0,0 +1,99 @@
+import GlimmerComponent from "discourse/components/glimmer";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import Session from "discourse/models/session";
+
+export default class UserMenuItemsList extends GlimmerComponent {
+ @tracked loading = false;
+ @tracked items = [];
+
+ constructor() {
+ super(...arguments);
+ this._load();
+ }
+
+ get itemsCacheKey() {}
+
+ get showAll() {
+ return false;
+ }
+
+ get showAllHref() {
+ throw new Error(
+ `the showAllHref getter must be implemented in ${this.constructor.name}`
+ );
+ }
+
+ get showAllTitle() {}
+
+ get showDismiss() {
+ return false;
+ }
+
+ get dismissTitle() {}
+
+ get emptyStateComponent() {
+ return "user-menu/items-list-empty-state";
+ }
+
+ fetchItems() {
+ throw new Error(
+ `the fetchItems method must be implemented in ${this.constructor.name}`
+ );
+ }
+
+ refreshList() {
+ this._load();
+ }
+
+ dismissWarningModal() {
+ return null;
+ }
+
+ _load() {
+ const cached = this._getCachedItems();
+ if (cached?.length) {
+ this.items = cached;
+ } else {
+ this.loading = true;
+ }
+ this.fetchItems()
+ .then((items) => {
+ const valid = items.every((item) => {
+ if (!item.userMenuComponent) {
+ // eslint-disable-next-line no-console
+ console.error("userMenuComponent property is blank on", item);
+ return false;
+ }
+ return true;
+ });
+ if (!valid) {
+ throw new Error("userMenuComponent must be present on all items");
+ }
+ this._setCachedItems(items);
+ this.items = items;
+ })
+ .finally(() => (this.loading = false));
+ }
+
+ _getCachedItems() {
+ const key = this.itemsCacheKey;
+ if (key) {
+ return Session.currentProp(`user-menu-items:${key}`);
+ }
+ }
+
+ _setCachedItems(newItems) {
+ const key = this.itemsCacheKey;
+ if (key) {
+ Session.currentProp(`user-menu-items:${key}`, newItems);
+ }
+ }
+
+ @action
+ dismissButtonClick() {
+ throw new Error(
+ `dismissButtonClick must be implemented in ${this.constructor.name}.`
+ );
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs b/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs
new file mode 100644
index 00000000000..26cef6dd1a5
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/menu.hbs
@@ -0,0 +1,50 @@
+
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu.js b/app/assets/javascripts/discourse/app/components/user-menu/menu.js
new file mode 100644
index 00000000000..9a2dc9149c5
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/menu.js
@@ -0,0 +1,55 @@
+import GlimmerComponent from "discourse/components/glimmer";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+
+const DEFAULT_TAB_ID = "all-notifications";
+const DEFAULT_PANEL_COMPONENT = "user-menu/notifications-list";
+
+export default class UserMenu extends GlimmerComponent {
+ @tracked currentTabId = DEFAULT_TAB_ID;
+ @tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT;
+
+ get topTabs() {
+ const tabs = this._coreTopTabs;
+ return tabs.map((tab, index) => {
+ tab.position = index;
+ return tab;
+ });
+ }
+
+ get bottomTabs() {
+ const topTabsLength = this.topTabs.length;
+ return this._coreBottomTabs.map((tab, index) => {
+ tab.position = index + topTabsLength;
+ return tab;
+ });
+ }
+
+ get _coreTopTabs() {
+ return [
+ {
+ id: DEFAULT_TAB_ID,
+ icon: "bell",
+ panelComponent: DEFAULT_PANEL_COMPONENT,
+ },
+ ];
+ }
+
+ get _coreBottomTabs() {
+ return [
+ {
+ id: "preferences",
+ icon: "user-cog",
+ href: `${this.currentUser.path}/preferences`,
+ },
+ ];
+ }
+
+ @action
+ changeTab(tab) {
+ if (this.currentTabId !== tab.id) {
+ this.currentTabId = tab.id;
+ this.currentPanelComponent = tab.panelComponent;
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/notification-item.hbs b/app/assets/javascripts/discourse/app/components/user-menu/notification-item.hbs
new file mode 100644
index 00000000000..fcb6c90b1ec
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/notification-item.hbs
@@ -0,0 +1,28 @@
+
+
+ {{d-icon this.icon}}
+
+ {{#if this.label}}
+ {{#if this.wrapLabel}}
+
+ {{this.label}}
+
+ {{else}}
+ {{this.label}}
+ {{/if}}
+ {{/if}}
+ {{#if this.description}}
+
+ {{this.description}}
+
+ {{/if}}
+
+
+
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/notification-item.js b/app/assets/javascripts/discourse/app/components/user-menu/notification-item.js
new file mode 100644
index 00000000000..007cc63830a
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/notification-item.js
@@ -0,0 +1,107 @@
+import GlimmerComponent from "discourse/components/glimmer";
+import { formatUsername, postUrl } from "discourse/lib/utilities";
+import { userPath } from "discourse/lib/url";
+import { setTransientHeader } from "discourse/lib/ajax";
+import { action } from "@ember/object";
+import { emojiUnescape } from "discourse/lib/text";
+import { htmlSafe } from "@ember/template";
+import getURL from "discourse-common/lib/get-url";
+import cookie from "discourse/lib/cookie";
+import I18n from "I18n";
+
+export default class UserMenuNotificationItem extends GlimmerComponent {
+ get className() {
+ const classes = [];
+ if (this.notification.read) {
+ classes.push("read");
+ }
+ if (this.notificationName) {
+ classes.push(this.notificationName.replace(/_/g, "-"));
+ }
+ if (this.notification.is_warning) {
+ classes.push("is-warning");
+ }
+ return classes.join(" ");
+ }
+
+ get linkHref() {
+ if (this.topicId) {
+ return postUrl(
+ this.notification.slug,
+ this.topicId,
+ this.notification.post_number
+ );
+ }
+ if (this.notification.data.group_id) {
+ return userPath(
+ `${this.notification.data.username}/messages/${this.notification.data.group_name}`
+ );
+ }
+ }
+
+ get linkTitle() {
+ if (this.notificationName) {
+ return I18n.t(`notifications.titles.${this.notificationName}`);
+ } else {
+ return "";
+ }
+ }
+
+ get icon() {
+ return `notification.${this.notificationName}`;
+ }
+
+ get label() {
+ return this.username;
+ }
+
+ get wrapLabel() {
+ return true;
+ }
+
+ get labelWrapperClasses() {}
+
+ get username() {
+ return formatUsername(this.notification.data.display_username);
+ }
+
+ get description() {
+ const description =
+ emojiUnescape(this.notification.fancy_title) ||
+ this.notification.data.topic_title;
+
+ if (this.descriptionHtmlSafe) {
+ return htmlSafe(description);
+ } else {
+ return description;
+ }
+ }
+
+ get descriptionElementClasses() {}
+
+ get descriptionHtmlSafe() {
+ return !!this.notification.fancy_title;
+ }
+
+ // the following props are helper props -- they're never referenced directly in the hbs template
+ get notification() {
+ return this.args.item;
+ }
+
+ get topicId() {
+ return this.notification.topic_id;
+ }
+
+ get notificationName() {
+ return this.site.notificationLookup[this.notification.notification_type];
+ }
+
+ @action
+ onClick() {
+ if (!this.notification.read) {
+ this.notification.set("read", true);
+ setTransientHeader("Discourse-Clear-Notifications", this.notification.id);
+ cookie("cn", this.notification.id, { path: getURL("/") });
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.hbs b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.hbs
new file mode 100644
index 00000000000..9c850d8a4c2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.hbs
@@ -0,0 +1,14 @@
+
+
+ {{i18n "user.no_notifications_title"}}
+
+
+
+ {{html-safe (i18n
+ "user.no_notifications_body"
+ icon=(d-icon "bell")
+ preferencesUrl=(get-url "/my/preferences/notifications")
+ )}}
+
+
+
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.js b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.js
new file mode 100644
index 00000000000..ae818afeed4
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list-empty-state.js
@@ -0,0 +1,3 @@
+import GlimmerComponent from "discourse/components/glimmer";
+
+export default class UserMenuNotificationsListEmptyState extends GlimmerComponent {}
diff --git a/app/assets/javascripts/discourse/app/components/user-menu/notifications-list.js b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list.js
new file mode 100644
index 00000000000..f9c659d6a31
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/user-menu/notifications-list.js
@@ -0,0 +1,74 @@
+import UserMenuItemsList from "discourse/components/user-menu/items-list";
+import I18n from "I18n";
+import { action } from "@ember/object";
+
+export default class UserMenuNotificationsList extends UserMenuItemsList {
+ get filterByTypes() {
+ return null;
+ }
+
+ get showAll() {
+ return true;
+ }
+
+ get showAllHref() {
+ return `${this.currentUser.path}/notifications`;
+ }
+
+ get showAllTitle() {
+ return I18n.t("user_menu.view_all_notifications");
+ }
+
+ get showDismiss() {
+ return this.items.some((item) => !item.read);
+ }
+
+ get dismissTitle() {
+ return I18n.t("user.dismiss_notifications_tooltip");
+ }
+
+ get itemsCacheKey() {
+ let key = "recent-notifications";
+ const types = this.filterByTypes?.toString();
+ if (types) {
+ key += `-type-${types}`;
+ }
+ return key;
+ }
+
+ get emptyStateComponent() {
+ if (this.constructor === UserMenuNotificationsList) {
+ return "user-menu/notifications-list-empty-state";
+ } else {
+ return super.emptyStateComponent;
+ }
+ }
+
+ fetchItems() {
+ const params = {
+ limit: 30,
+ recent: true,
+ bump_last_seen_reviewable: true,
+ silent: this.currentUser.enforcedSecondFactor,
+ };
+
+ const types = this.filterByTypes?.toString();
+ if (types) {
+ params.filter_by_types = types;
+ }
+ return this.store
+ .findStale("notification", params)
+ .refresh()
+ .then((c) => c.content);
+ }
+
+ dismissWarningModal() {
+ // TODO: add warning modal when there are unread high pri notifications
+ return null;
+ }
+
+ @action
+ dismissButtonClick() {
+ // TODO
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js
index 1b7eb903e84..a25a9908e24 100644
--- a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js
+++ b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js
@@ -1,4 +1,4 @@
-import EmberObject, { set } from "@ember/object";
+import { set } from "@ember/object";
// Subscribes to user events on the message bus
import {
alertChannel,
@@ -12,6 +12,7 @@ import {
unsubscribe as unsubscribePushNotifications,
} from "discourse/lib/push-notifications";
import { isTesting } from "discourse-common/config/environment";
+import Notification from "discourse/models/notification";
export default {
name: "subscribe-user-notifications",
@@ -88,7 +89,7 @@ export default {
oldNotifications.insertAt(
insertPosition,
- EmberObject.create(lastNotification)
+ Notification.create(lastNotification)
);
}
diff --git a/app/assets/javascripts/discourse/app/models/notification.js b/app/assets/javascripts/discourse/app/models/notification.js
new file mode 100644
index 00000000000..8994d66a89a
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/models/notification.js
@@ -0,0 +1,15 @@
+import RestModel from "discourse/models/rest";
+import { tracked } from "@glimmer/tracking";
+
+const DEFAULT_ITEM = "user-menu/notification-item";
+const _componentForType = {};
+
+export default class Notification extends RestModel {
+ @tracked read;
+
+ get userMenuComponent() {
+ const component =
+ _componentForType[this.site.notificationLookup[this.notification_type]];
+ return component || DEFAULT_ITEM;
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-menu-wrapper.hbs b/app/assets/javascripts/discourse/app/templates/components/user-menu-wrapper.hbs
new file mode 100644
index 00000000000..0258c0d0fbc
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/user-menu-wrapper.hbs
@@ -0,0 +1 @@
+
diff --git a/app/assets/javascripts/discourse/app/widgets/component-connector.js b/app/assets/javascripts/discourse/app/widgets/component-connector.js
index 8799d2770f9..2b2cfde090d 100644
--- a/app/assets/javascripts/discourse/app/widgets/component-connector.js
+++ b/app/assets/javascripts/discourse/app/widgets/component-connector.js
@@ -2,16 +2,26 @@ import { getOwner } from "@ember/application";
import { scheduleOnce } from "@ember/runloop";
export default class ComponentConnector {
- constructor(widget, componentName, opts, trackedProperties) {
+ constructor(
+ widget,
+ componentName,
+ opts,
+ trackedProperties,
+ { applyStyle = true } = {}
+ ) {
this.widget = widget;
this.opts = opts;
this.componentName = componentName;
this.trackedProperties = trackedProperties || [];
+ this.applyStyle = applyStyle;
+ this._component = null;
}
init() {
const elem = document.createElement("div");
- elem.style.display = "inline-flex";
+ if (this.applyStyle) {
+ elem.style.display = "inline-flex";
+ }
elem.className = "widget-component-connector";
this.elem = elem;
scheduleOnce("afterRender", this, this.connectComponent);
@@ -19,6 +29,10 @@ export default class ComponentConnector {
return this.elem;
}
+ destroy() {
+ this._component?.destroy();
+ }
+
update(prev) {
// mutated external properties might not correctly update the underlying component
// in this case we can define trackedProperties, if different from previous
@@ -54,6 +68,7 @@ export default class ComponentConnector {
mounted._connected.push(component);
component.renderer.appendTo(component, elem);
+ this._component = component;
}
}
diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js
index 8055d2025c2..aaacaaf7391 100644
--- a/app/assets/javascripts/discourse/app/widgets/header.js
+++ b/app/assets/javascripts/discourse/app/widgets/header.js
@@ -11,6 +11,7 @@ import { schedule } from "@ember/runloop";
import { scrollTop } from "discourse/mixins/scroll-top";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { logSearchLinkClick } from "discourse/lib/search";
+import ComponentConnector from "discourse/widgets/component-connector";
const _extraHeaderIcons = [];
@@ -330,6 +331,24 @@ export function attachAdditionalPanel(name, toggle, transformAttrs) {
additionalPanels.push({ name, toggle, transformAttrs });
}
+createWidget("revamped-user-menu-wrapper", {
+ buildAttributes() {
+ return { "data-click-outside": true };
+ },
+
+ html() {
+ return [
+ new ComponentConnector(this, "user-menu-wrapper", {}, [], {
+ applyStyle: false,
+ }),
+ ];
+ },
+
+ clickOutside() {
+ this.sendWidgetAction("toggleUserMenu");
+ },
+});
+
export default createWidget("header", {
tagName: "header.d-header.clearfix",
buildKey: () => `header`,
@@ -383,7 +402,11 @@ export default createWidget("header", {
} else if (state.hamburgerVisible) {
panels.push(this.attach("hamburger-menu"));
} else if (state.userVisible) {
- panels.push(this.attach("user-menu"));
+ if (this.currentUser.redesigned_user_menu_enabled) {
+ panels.push(this.attach("revamped-user-menu-wrapper", {}));
+ } else {
+ panels.push(this.attach("user-menu"));
+ }
}
additionalPanels.map((panel) => {
diff --git a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js
index 199c05b079f..b1537bc75ba 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js
@@ -1,7 +1,7 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, render } from "@ember/test-helpers";
-import { count, exists } from "discourse/tests/helpers/qunit-helpers";
+import { count, exists, query } from "discourse/tests/helpers/qunit-helpers";
import pretender from "discourse/tests/helpers/create-pretender";
import { hbs } from "ember-cli-htmlbars";
@@ -49,4 +49,38 @@ module("Integration | Component | site-header", function (hooks) {
// Click anywhere
await click("header.d-header");
});
+
+ test("user avatar is highlighted when the user receives the first notification", async function (assert) {
+ this.currentUser.set("all_unread_notifications", 1);
+ this.currentUser.set("redesigned_user_menu_enabled", true);
+ this.currentUser.set("read_first_notification", false);
+ await render(hbs``);
+ assert.ok(exists(".ring-first-notification"));
+ });
+
+ test("user avatar is not highlighted when the user receives notifications beyond the first one", async function (assert) {
+ this.currentUser.set("redesigned_user_menu_enabled", true);
+ this.currentUser.set("all_unread_notifications", 1);
+ this.currentUser.set("read_first_notification", true);
+ await render(hbs``);
+ assert.ok(!exists(".ring-first-notification"));
+ });
+
+ test("hamburger menu icon shows pending reviewables count", async function (assert) {
+ this.currentUser.set("reviewable_count", 1);
+ await render(hbs``);
+ let pendingReviewablesBadge = query(
+ ".hamburger-dropdown .badge-notification"
+ );
+ assert.strictEqual(pendingReviewablesBadge.textContent, "1");
+ });
+
+ test("clicking outside the revamped menu closes it", async function (assert) {
+ this.currentUser.set("redesigned_user_menu_enabled", true);
+ await render(hbs``);
+ await click(".header-dropdown-toggle.current-user");
+ assert.ok(exists(".user-menu.revamped"));
+ await click("header.d-header");
+ assert.ok(!exists(".user-menu.revamped"));
+ });
});
diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js
new file mode 100644
index 00000000000..45cf7c6c201
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/user-menu/menu-test.js
@@ -0,0 +1,55 @@
+import { module, test } from "qunit";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { query, queryAll } from "discourse/tests/helpers/qunit-helpers";
+import { render } from "@ember/test-helpers";
+import { hbs } from "ember-cli-htmlbars";
+
+module("Integration | Component | user-menu", function (hooks) {
+ setupRenderingTest(hooks);
+
+ const template = hbs``;
+
+ test("notifications panel has a11y attributes", async function (assert) {
+ await render(template);
+ const panel = query("#quick-access-all-notifications");
+ assert.strictEqual(panel.getAttribute("tabindex"), "-1");
+ assert.strictEqual(
+ panel.getAttribute("aria-labelledby"),
+ "user-menu-button-all-notifications"
+ );
+ });
+
+ test("active tab has a11y attributes that indicate it's active", async function (assert) {
+ await render(template);
+ const activeTab = query(".top-tabs.tabs-list .btn.active");
+ assert.strictEqual(activeTab.getAttribute("tabindex"), "0");
+ assert.strictEqual(activeTab.getAttribute("aria-selected"), "true");
+ });
+
+ test("the menu has a group of tabs at the top", async function (assert) {
+ await render(template);
+ const tabs = queryAll(".top-tabs.tabs-list .btn");
+ assert.strictEqual(tabs.length, 1);
+ ["all-notifications"].forEach((tab, index) => {
+ assert.strictEqual(tabs[index].id, `user-menu-button-${tab}`);
+ assert.strictEqual(
+ tabs[index].getAttribute("data-tab-number"),
+ index.toString()
+ );
+ assert.strictEqual(
+ tabs[index].getAttribute("aria-controls"),
+ `quick-access-${tab}`
+ );
+ });
+ });
+
+ test("the menu has a group of tabs at the bottom", async function (assert) {
+ await render(template);
+ const tabs = queryAll(".bottom-tabs.tabs-list .btn");
+ assert.strictEqual(tabs.length, 1);
+ const preferencesTab = tabs[0];
+ assert.ok(preferencesTab.href.endsWith("/u/eviltrout/preferences"));
+ assert.strictEqual(preferencesTab.getAttribute("data-tab-number"), "1");
+ assert.strictEqual(preferencesTab.getAttribute("tabindex"), "-1");
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-menu/notification-item-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-menu/notification-item-test.js
new file mode 100644
index 00000000000..5c25b81bd8a
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/user-menu/notification-item-test.js
@@ -0,0 +1,198 @@
+import { module, test } from "qunit";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { exists, query } from "discourse/tests/helpers/qunit-helpers";
+import { render, settled } from "@ember/test-helpers";
+import { deepMerge } from "discourse-common/lib/object";
+import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
+import Notification from "discourse/models/notification";
+import { hbs } from "ember-cli-htmlbars";
+import I18n from "I18n";
+
+function getNotification(overrides = {}) {
+ return Notification.create(
+ deepMerge(
+ {
+ id: 11,
+ user_id: 1,
+ notification_type: NOTIFICATION_TYPES.mentioned,
+ read: false,
+ high_priority: false,
+ created_at: "2022-07-01T06:00:32.173Z",
+ post_number: 113,
+ topic_id: 449,
+ fancy_title: "This is fancy title <a>!",
+ slug: "this-is-fancy-title",
+ data: {
+ topic_title: "this is title before it becomes fancy !",
+ display_username: "osama",
+ original_post_id: 1,
+ original_post_type: 1,
+ original_username: "velesin",
+ },
+ },
+ overrides
+ )
+ );
+}
+
+module(
+ "Integration | Component | user-menu | notification-item",
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ const template = hbs``;
+
+ test("pushes `read` to the classList if the notification is read", async function (assert) {
+ this.set("notification", getNotification());
+ this.notification.read = false;
+ await render(template);
+ assert.ok(!exists("li.read"));
+ assert.ok(exists("li"));
+
+ this.notification.read = true;
+ await settled();
+
+ assert.ok(
+ exists("li.read"),
+ "the item re-renders when the read property is updated"
+ );
+ });
+
+ test("pushes the notification type name to the classList", async function (assert) {
+ this.set("notification", getNotification());
+ await render(template);
+ let item = query("li");
+ assert.strictEqual(item.className, "mentioned");
+
+ this.set(
+ "notification",
+ getNotification({
+ notification_type: NOTIFICATION_TYPES.private_message,
+ })
+ );
+ await settled();
+
+ assert.ok(
+ exists("li.private-message"),
+ "replaces underscores in type name with dashes"
+ );
+ });
+
+ test("pushes is-warning to the classList if the notification originates from a warning PM", async function (assert) {
+ this.set("notification", getNotification({ is_warning: true }));
+ await render(template);
+ assert.ok(exists("li.is-warning"));
+ });
+
+ test("doesn't push is-warning to the classList if the notification doesn't originate from a warning PM", async function (assert) {
+ this.set("notification", getNotification());
+ await render(template);
+ assert.ok(!exists("li.is-warning"));
+ assert.ok(exists("li"));
+ });
+
+ test("the item's href links to the topic that the notification originates from", async function (assert) {
+ this.set("notification", getNotification());
+ await render(template);
+ const link = query("li a");
+ assert.ok(link.href.endsWith("/t/this-is-fancy-title/449/113"));
+ });
+
+ test("the item's href links to the group messages if the notification is for a group messages", async function (assert) {
+ this.set(
+ "notification",
+ getNotification({
+ topic_id: null,
+ post_number: null,
+ slug: null,
+ data: {
+ group_id: 33,
+ group_name: "grouperss",
+ username: "ossaama",
+ },
+ })
+ );
+ await render(template);
+ const link = query("li a");
+ assert.ok(link.href.endsWith("/u/ossaama/messages/grouperss"));
+ });
+
+ test("the item's link has a title for accessibility", async function (assert) {
+ this.set("notification", getNotification());
+ await render(template);
+ const link = query("li a");
+ assert.strictEqual(link.title, I18n.t("notifications.titles.mentioned"));
+ });
+
+ test("has elements for label and description", async function (assert) {
+ this.set("notification", getNotification());
+ await render(template);
+ const label = query("li a .notification-label");
+ const description = query("li a .notification-description");
+
+ assert.strictEqual(
+ label.textContent.trim(),
+ "osama",
+ "the label's content is the username by default"
+ );
+
+ assert.strictEqual(
+ description.textContent.trim(),
+ "This is fancy title !",
+ "the description defaults to the fancy_title"
+ );
+ });
+
+ test("the description falls back to topic_title from data if fancy_title is absent", async function (assert) {
+ this.set(
+ "notification",
+ getNotification({
+ fancy_title: null,
+ })
+ );
+ await render(template);
+ const description = query("li a .notification-description");
+
+ assert.strictEqual(
+ description.textContent.trim(),
+ "this is title before it becomes fancy !",
+ "topic_title from data is rendered safely"
+ );
+ });
+
+ test("fancy_title is emoji-unescaped", async function (assert) {
+ this.set(
+ "notification",
+ getNotification({
+ fancy_title: "title with emoji :phone:",
+ })
+ );
+ await render(template);
+ assert.ok(
+ exists("li a .notification-description img.emoji"),
+ "emojis are unescaped when fancy_title is used for description"
+ );
+ });
+
+ test("topic_title from data is not emoji-unescaped", async function (assert) {
+ this.set(
+ "notification",
+ getNotification({
+ fancy_title: null,
+ data: {
+ topic_title: "unsafe title with unescaped emoji :phone:",
+ },
+ })
+ );
+ await render(template);
+ const description = query("li a .notification-description");
+
+ assert.strictEqual(
+ description.textContent.trim(),
+ "unsafe title with unescaped emoji :phone:",
+ "emojis aren't unescaped when topic title is not safe"
+ );
+ assert.ok(!query("img"), "no exists");
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-menu/notifications-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-menu/notifications-list-test.js
new file mode 100644
index 00000000000..395cf48eb89
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/user-menu/notifications-list-test.js
@@ -0,0 +1,103 @@
+import { module, test } from "qunit";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { exists, query } from "discourse/tests/helpers/qunit-helpers";
+import { render } from "@ember/test-helpers";
+import { cloneJSON } from "discourse-common/lib/object";
+import NotificationFixtures from "discourse/tests/fixtures/notification-fixtures";
+import { hbs } from "ember-cli-htmlbars";
+import pretender from "discourse/tests/helpers/create-pretender";
+import I18n from "I18n";
+
+function getNotificationsData() {
+ return cloneJSON(NotificationFixtures["/notifications"].notifications);
+}
+
+module(
+ "Integration | Component | user-menu | notifications-list",
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ let notificationsData = getNotificationsData();
+ let queryParams = null;
+ hooks.beforeEach(() => {
+ pretender.get("/notifications", (request) => {
+ queryParams = request.queryParams;
+ return [
+ 200,
+ { "Content-Type": "application/json" },
+ { notifications: notificationsData },
+ ];
+ });
+
+ pretender.put("/notifications/mark-read", () => {
+ return [200, { "Content-Type": "application/json" }, { success: true }];
+ });
+ });
+
+ hooks.afterEach(() => {
+ notificationsData = getNotificationsData();
+ queryParams = null;
+ });
+
+ const template = hbs``;
+
+ test("empty state when there are no notifications", async function (assert) {
+ notificationsData.clear();
+ await render(template);
+ assert.ok(exists(".empty-state .empty-state-title"));
+ assert.ok(exists(".empty-state .empty-state-body"));
+ });
+
+ test("doesn't set filter_by_types in the params of the request that fetches the notifications", async function (assert) {
+ await render(template);
+ assert.strictEqual(
+ queryParams.filter_by_types,
+ undefined,
+ "filter_by_types param is absent"
+ );
+ });
+
+ test("displays a show all button that takes to the notifications page of the current user", async function (assert) {
+ await render(template);
+ const showAllBtn = query(".panel-body-bottom .btn.show-all");
+ assert.ok(
+ showAllBtn.href.endsWith("/u/eviltrout/notifications"),
+ "it takes you to the notifications page"
+ );
+ assert.strictEqual(
+ showAllBtn.getAttribute("title"),
+ I18n.t("user_menu.view_all_notifications"),
+ "title attribute is present"
+ );
+ });
+
+ test("has a dismiss button if some notifications are not read", async function (assert) {
+ notificationsData.forEach((notification) => {
+ notification.read = true;
+ });
+ notificationsData[0].read = false;
+ await render(template);
+ const dismissButton = query(
+ ".panel-body-bottom .btn.notifications-dismiss"
+ );
+ assert.strictEqual(
+ dismissButton.textContent.trim(),
+ I18n.t("user.dismiss"),
+ "dismiss button has a label"
+ );
+ assert.strictEqual(
+ dismissButton.getAttribute("title"),
+ I18n.t("user.dismiss_notifications_tooltip"),
+ "dismiss button has title attribute"
+ );
+ });
+
+ test("doesn't have a dismiss button if all notifications are read", async function (assert) {
+ notificationsData.forEach((notification) => {
+ notification.read = true;
+ });
+ await render(template);
+ assert.ok(!exists(".panel-body-bottom .btn.notifications-dismiss"));
+ });
+ }
+);
diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss
index 081b468ff32..93511e16027 100644
--- a/app/assets/stylesheets/common/base/menu-panel.scss
+++ b/app/assets/stylesheets/common/base/menu-panel.scss
@@ -90,6 +90,70 @@
}
}
+.user-menu.revamped {
+ right: 0;
+ width: 320px;
+ padding: 0;
+
+ .panel-body-bottom {
+ flex: 0;
+ }
+
+ .menu-tabs-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ border-left: 1px solid var(--primary-low);
+ }
+
+ .tabs-list {
+ display: flex;
+ flex-direction: column;
+
+ .btn {
+ display: flex;
+ padding: 0.857em;
+ position: relative;
+
+ .d-icon {
+ color: var(--primary-medium);
+ }
+
+ .badge-notification {
+ background-color: var(--tertiary-med-or-tertiary);
+ position: absolute;
+ right: 6px;
+ top: 6px;
+ font-size: var(--font-down-3);
+ }
+
+ &.active {
+ background-color: var(--tertiary-low);
+ }
+ &:hover {
+ background-color: var(--highlight-medium);
+ }
+ }
+ }
+
+ .panel-body-contents {
+ display: flex;
+ flex-direction: row;
+ }
+
+ .quick-access-panel {
+ width: 320px;
+ padding: 0.75em;
+ justify-content: space-between;
+ box-sizing: border-box;
+
+ .double-user,
+ .multi-user {
+ white-space: unset;
+ }
+ }
+}
+
.hamburger-panel {
a.widget-link {
width: 100%;
@@ -351,7 +415,8 @@
color: var(--danger);
}
}
- .read {
+ .read,
+ .reviewed {
background-color: var(--secondary);
}
.none {
diff --git a/app/assets/stylesheets/desktop/menu-panel.scss b/app/assets/stylesheets/desktop/menu-panel.scss
index a6e7bbaf5b1..f70261e4018 100644
--- a/app/assets/stylesheets/desktop/menu-panel.scss
+++ b/app/assets/stylesheets/desktop/menu-panel.scss
@@ -1,3 +1,9 @@
-.menu-panel .panel-body {
- max-height: calc(100vh - 100px);
+.menu-panel {
+ &.user-menu.revamped {
+ width: unset;
+ }
+
+ .panel-body {
+ max-height: calc(100vh - 100px);
+ }
}
diff --git a/app/assets/stylesheets/mobile/menu-panel.scss b/app/assets/stylesheets/mobile/menu-panel.scss
index 63c6114a360..8cd67cb4024 100644
--- a/app/assets/stylesheets/mobile/menu-panel.scss
+++ b/app/assets/stylesheets/mobile/menu-panel.scss
@@ -62,4 +62,8 @@
.panel-body-contents {
// 2em padding very useful for iOS Safari's overlayed bottom nav
padding-bottom: calc(env(safe-area-inset-bottom) + 2em);
+
+ .user-menu.revamped & {
+ height: 100%;
+ }
}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index b68a5c2d25d..a14251d2f63 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2546,6 +2546,10 @@ en:
not_logged_in_user: "user page with summary of current activity and preferences"
current_user: "go to your user page"
view_all: "view all %{tab}"
+ user_menu:
+ generic_no_items: "There are no items in this list."
+ sr_menu_tabs: "Menu tabs"
+ view_all_notifications: "view all notifications"
topics:
new_messages_marker: "last visit"
diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
index 68e783c03f3..c4a07f02c15 100644
--- a/lib/svg_sprite.rb
+++ b/lib/svg_sprite.rb
@@ -205,6 +205,7 @@ module SvgSprite
"unlock-alt",
"upload",
"user",
+ "user-cog",
"user-edit",
"user-friends",
"user-plus",