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}} + +
+ {{#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",