FEATURE: other notifications tab for redesign user menu (#18164)

This commit adds to the experimental user menu a new "other notifications" tab that's very similar to the "all notifications" tab, but with the main difference being that it doesn't show notification types that do have dedicated tabs in the menu (e.g. mentions, likes, replies etc.).

The rationale behind this is that the notification types that do have dedicated tabs tend to dominate the "all notifications" tab, leaving very small chances for the user to notice rarer or infrequent notification types. Adding a tab for all the other types gives the user a way to review those infrequent notification types.

Internal ticket: t72978.

Co-authored-by: OsamaSayegh <asooomaasoooma90@gmail.com>
This commit is contained in:
Krzysztof Kotlarek 2022-09-02 21:49:49 +10:00 committed by GitHub
parent bf6c9e0f28
commit 661a903a0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 143 additions and 35 deletions

View File

@ -1,13 +1,6 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
export default class UserMenuLikesNotificationsList extends UserMenuNotificationsList { export default class UserMenuLikesNotificationsList extends UserMenuNotificationsList {
get filterByTypes() {
// TODO(osama): reaction is a type used by the reactions plugin, but it's
// added here temporarily unitl we add a plugin API for extending
// filterByTypes in lists
return ["liked", "liked_consolidated", "reaction"];
}
get dismissTypes() { get dismissTypes() {
return this.filterByTypes; return this.filterByTypes;
} }

View File

@ -1,10 +1,6 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
export default class UserMenuMentionsNotificationsList extends UserMenuNotificationsList { export default class UserMenuMentionsNotificationsList extends UserMenuNotificationsList {
get filterByTypes() {
return ["mentioned"];
}
get dismissTypes() { get dismissTypes() {
return this.filterByTypes; return this.filterByTypes;
} }

View File

@ -6,7 +6,7 @@
class="quick-access-panel" class="quick-access-panel"
tabindex="-1" tabindex="-1"
aria-labelledby={{concat "user-menu-button-" this.currentTabId}}> aria-labelledby={{concat "user-menu-button-" this.currentTabId}}>
{{component this.currentPanelComponent closeUserMenu=@closeUserMenu}} {{component this.currentPanelComponent closeUserMenu=@closeUserMenu filterByTypes=this.currentNotificationTypes}}
</div> </div>
<div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}> <div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}>
<div class="top-tabs tabs-list"> <div class="top-tabs tabs-list">

View File

@ -41,6 +41,10 @@ const CORE_TOP_TABS = [
get count() { get count() {
return this.getUnreadCountForType("replied"); return this.getUnreadCountForType("replied");
} }
get notificationTypes() {
return ["replied"];
}
}, },
class extends UserMenuTab { class extends UserMenuTab {
@ -59,6 +63,10 @@ const CORE_TOP_TABS = [
get count() { get count() {
return this.getUnreadCountForType("mentioned"); return this.getUnreadCountForType("mentioned");
} }
get notificationTypes() {
return ["mentioned"];
}
}, },
class extends UserMenuTab { class extends UserMenuTab {
@ -81,6 +89,13 @@ const CORE_TOP_TABS = [
get count() { get count() {
return this.getUnreadCountForType("liked"); return this.getUnreadCountForType("liked");
} }
// TODO(osama): reaction is a type used by the reactions plugin, but it's
// added here temporarily unitl we add a plugin API for extending
// filterByTypes in lists
get notificationTypes() {
return ["liked", "liked_consolidated", "reaction"];
}
}, },
class extends UserMenuTab { class extends UserMenuTab {
@ -105,6 +120,9 @@ const CORE_TOP_TABS = [
this.siteSettings.enable_personal_messages || this.currentUser.staff this.siteSettings.enable_personal_messages || this.currentUser.staff
); );
} }
get notificationTypes() {
return ["private_message"];
}
}, },
class extends UserMenuTab { class extends UserMenuTab {
@ -123,6 +141,10 @@ const CORE_TOP_TABS = [
get count() { get count() {
return this.getUnreadCountForType("bookmark_reminder"); return this.getUnreadCountForType("bookmark_reminder");
} }
get notificationTypes() {
return ["bookmark_reminder"];
}
}, },
class extends UserMenuTab { class extends UserMenuTab {
@ -164,6 +186,35 @@ const CORE_BOTTOM_TABS = [
}, },
]; ];
const CORE_OTHER_NOTIFICATIONS_TAB = class extends UserMenuTab {
constructor(currentUser, siteSettings, site, otherNotificationTypes) {
super(...arguments);
this.otherNotificationTypes = otherNotificationTypes;
}
get id() {
return "other";
}
get icon() {
return "discourse-other-tab";
}
get panelComponent() {
return "user-menu/other-notifications-list";
}
get count() {
return this.otherNotificationTypes.reduce((sum, notificationType) => {
return sum + this.getUnreadCountForType(notificationType);
}, 0);
}
get notificationTypes() {
return this.otherNotificationTypes;
}
};
export default class UserMenu extends Component { export default class UserMenu extends Component {
@service currentUser; @service currentUser;
@service siteSettings; @service siteSettings;
@ -172,6 +223,7 @@ export default class UserMenu extends Component {
@tracked currentTabId = DEFAULT_TAB_ID; @tracked currentTabId = DEFAULT_TAB_ID;
@tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT; @tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT;
@tracked currentNotificationTypes;
constructor() { constructor() {
super(...arguments); super(...arguments);
@ -196,7 +248,6 @@ export default class UserMenu extends Component {
CUSTOM_TABS_CLASSES.forEach((tabClass) => { CUSTOM_TABS_CLASSES.forEach((tabClass) => {
const tab = new tabClass(this.currentUser, this.siteSettings, this.site); const tab = new tabClass(this.currentUser, this.siteSettings, this.site);
if (tab.shouldDisplay) { if (tab.shouldDisplay) {
// ensure the review queue tab is always last
if (reviewQueueTabIndex === -1) { if (reviewQueueTabIndex === -1) {
tabs.push(tab); tabs.push(tab);
} else { } else {
@ -206,6 +257,15 @@ export default class UserMenu extends Component {
} }
}); });
tabs.push(
new CORE_OTHER_NOTIFICATIONS_TAB(
this.currentUser,
this.siteSettings,
this.site,
this.#notificationTypesForTheOtherTab(tabs)
)
);
return tabs.map((tab, index) => { return tabs.map((tab, index) => {
tab.position = index; tab.position = index;
return tab; return tab;
@ -229,14 +289,14 @@ export default class UserMenu extends Component {
}); });
} }
get _coreBottomTabs() { #notificationTypesForTheOtherTab(tabs) {
return [ const usedNotificationTypes = tabs
{ .filter((tab) => tab.notificationTypes)
id: "preferences", .map((tab) => tab.notificationTypes)
icon: "user-cog", .flat();
href: `${this.currentUser.path}/preferences`, return Object.keys(this.site.notification_types).filter(
}, (notificationType) => !usedNotificationTypes.includes(notificationType)
]; );
} }
@action @action
@ -244,6 +304,7 @@ export default class UserMenu extends Component {
if (this.currentTabId !== tab.id) { if (this.currentTabId !== tab.id) {
this.currentTabId = tab.id; this.currentTabId = tab.id;
this.currentPanelComponent = tab.panelComponent; this.currentPanelComponent = tab.panelComponent;
this.currentNotificationTypes = tab.notificationTypes;
} }
} }

View File

@ -15,7 +15,7 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
@service store; @service store;
get filterByTypes() { get filterByTypes() {
return null; return this.args.filterByTypes;
} }
get dismissTypes() { get dismissTypes() {

View File

@ -0,0 +1,16 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
import { inject as service } from "@ember/service";
export default class UserMenuOtherNotificationsList extends UserMenuNotificationsList {
@service currentUser;
@service siteSettings;
@service site;
get dismissTypes() {
return this.filterByTypes;
}
dismissWarningModal() {
return null;
}
}

View File

@ -1,10 +1,6 @@
import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list";
export default class UserMenuRepliesNotificationsList extends UserMenuNotificationsList { export default class UserMenuRepliesNotificationsList extends UserMenuNotificationsList {
get filterByTypes() {
return ["replied"];
}
get dismissTypes() { get dismissTypes() {
return this.filterByTypes; return this.filterByTypes;
} }

View File

@ -43,6 +43,11 @@ export default class UserMenuTab {
throw new Error("not implemented"); throw new Error("not implemented");
} }
/**
* @returns {Array} Notification types displayed in tab. Those notifications will be removed from "other" tab.
*/
get notificationTypes() {}
getUnreadCountForType(type) { getUnreadCountForType(type) {
const key = `grouped_unread_notifications.${this.site.notification_types[type]}`; const key = `grouped_unread_notifications.${this.site.notification_types[type]}`;
// we're retrieving the value with get() so that Ember tracks the property // we're retrieving the value with get() so that Ember tracks the property

View File

@ -135,6 +135,7 @@ acceptance("User menu", function (needs) {
"user-menu-button-custom-tab-1": "6", "user-menu-button-custom-tab-1": "6",
"user-menu-button-custom-tab-2": "7", "user-menu-button-custom-tab-2": "7",
"user-menu-button-review-queue": "8", "user-menu-button-review-queue": "8",
"user-menu-button-other": "9",
}; };
await visit("/"); await visit("/");
@ -161,7 +162,7 @@ acceptance("User menu", function (needs) {
); );
assert.strictEqual( assert.strictEqual(
query(".tabs-list.bottom-tabs .btn").dataset.tabNumber, query(".tabs-list.bottom-tabs .btn").dataset.tabNumber,
"9", "10",
"bottom tab has the correct data-tab-number" "bottom tab has the correct data-tab-number"
); );
@ -528,6 +529,8 @@ acceptance("User menu - Dismiss button", function (needs) {
grouped_unread_notifications: { grouped_unread_notifications: {
[NOTIFICATION_TYPES.bookmark_reminder]: 103, [NOTIFICATION_TYPES.bookmark_reminder]: 103,
[NOTIFICATION_TYPES.private_message]: 89, [NOTIFICATION_TYPES.private_message]: 89,
[NOTIFICATION_TYPES.votes_released]: 1,
[NOTIFICATION_TYPES.code_review_commit_approved]: 3,
}, },
}); });
@ -713,4 +716,27 @@ acceptance("User menu - Dismiss button", function (needs) {
"mark-read request is sent without a confirmation modal" "mark-read request is sent without a confirmation modal"
); );
}); });
test("doesn't show confirmation modal for the other notifications list", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-other");
let repliesBadgeNotification = query(
"#user-menu-button-other .badge-notification"
);
assert.strictEqual(
repliesBadgeNotification.textContent.trim(),
"4",
"badge shows the right count"
);
await click(".user-menu .notifications-dismiss");
assert.ok(!exists("#user-menu-button-other .badge-notification"));
assert.ok(
markRead,
"mark-read request is sent without a confirmation modal"
);
});
}); });

View File

@ -162,6 +162,7 @@ module("Integration | Component | site-header", function (hooks) {
await triggerKeyEvent(document, "keydown", "ArrowDown"); await triggerKeyEvent(document, "keydown", "ArrowDown");
await triggerKeyEvent(document, "keydown", "ArrowDown"); await triggerKeyEvent(document, "keydown", "ArrowDown");
await triggerKeyEvent(document, "keydown", "ArrowDown"); await triggerKeyEvent(document, "keydown", "ArrowDown");
await triggerKeyEvent(document, "keydown", "ArrowDown");
focusedTab = document.activeElement; focusedTab = document.activeElement;
assert.strictEqual( assert.strictEqual(

View File

@ -48,7 +48,7 @@ module("Integration | Component | user-menu", function (hooks) {
test("the menu has a group of tabs at the top", async function (assert) { test("the menu has a group of tabs at the top", async function (assert) {
await render(template); await render(template);
const tabs = queryAll(".top-tabs.tabs-list .btn"); const tabs = queryAll(".top-tabs.tabs-list .btn");
assert.strictEqual(tabs.length, 6); assert.strictEqual(tabs.length, 7);
[ [
"all-notifications", "all-notifications",
"replies", "replies",
@ -72,7 +72,7 @@ module("Integration | Component | user-menu", function (hooks) {
assert.strictEqual(tabs.length, 1); assert.strictEqual(tabs.length, 1);
const profileTab = tabs[0]; const profileTab = tabs[0];
assert.strictEqual(profileTab.id, "user-menu-button-profile"); assert.strictEqual(profileTab.id, "user-menu-button-profile");
assert.strictEqual(profileTab.dataset.tabNumber, "6"); assert.strictEqual(profileTab.dataset.tabNumber, "7");
assert.strictEqual(profileTab.getAttribute("tabindex"), "-1"); assert.strictEqual(profileTab.getAttribute("tabindex"), "-1");
}); });
@ -82,11 +82,11 @@ module("Integration | Component | user-menu", function (hooks) {
assert.ok(!exists("#user-menu-button-likes")); assert.ok(!exists("#user-menu-button-likes"));
const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs
assert.strictEqual(tabs.length, 6); assert.strictEqual(tabs.length, 7);
assert.deepEqual( assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber), tabs.map((t) => t.dataset.tabNumber),
["0", "1", "2", "3", "4", "5"], ["0", "1", "2", "3", "4", "5", "6"],
"data-tab-number of the tabs has no gaps when the likes tab is hidden" "data-tab-number of the tabs has no gaps when the likes tab is hidden"
); );
}); });
@ -98,11 +98,11 @@ module("Integration | Component | user-menu", function (hooks) {
assert.strictEqual(tab.dataset.tabNumber, "6"); assert.strictEqual(tab.dataset.tabNumber, "6");
const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs
assert.strictEqual(tabs.length, 8); assert.strictEqual(tabs.length, 9);
assert.deepEqual( assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber), tabs.map((t) => t.dataset.tabNumber),
["0", "1", "2", "3", "4", "5", "6", "7"], ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"data-tab-number of the tabs has no gaps when the reviewables tab is show" "data-tab-number of the tabs has no gaps when the reviewables tab is show"
); );
}); });
@ -117,11 +117,11 @@ module("Integration | Component | user-menu", function (hooks) {
assert.ok(!exists("#user-menu-button-messages")); assert.ok(!exists("#user-menu-button-messages"));
const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs
assert.strictEqual(tabs.length, 6); assert.strictEqual(tabs.length, 7);
assert.deepEqual( assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber), tabs.map((t) => t.dataset.tabNumber),
["0", "1", "2", "3", "4", "5"], ["0", "1", "2", "3", "4", "5", "6"],
"data-tab-number of the tabs has no gaps when the messages tab is hidden" "data-tab-number of the tabs has no gaps when the messages tab is hidden"
); );
}); });

View File

@ -65,6 +65,7 @@ module SvgSprite
"discourse-compress", "discourse-compress",
"discourse-emojis", "discourse-emojis",
"discourse-expand", "discourse-expand",
"discourse-other-tab",
"download", "download",
"ellipsis-h", "ellipsis-h",
"ellipsis-v", "ellipsis-v",

View File

@ -40,4 +40,17 @@ Additional SVG icons
<path class="svg-arrow" d="M0 6s1.796-.013 4.67-3.615C5.851.9 6.93.006 8 0c1.07-.006 2.148.887 3.343 2.385C14.233 6.005 16 6 16 6H0z"/> <path class="svg-arrow" d="M0 6s1.796-.013 4.67-3.615C5.851.9 6.93.006 8 0c1.07-.006 2.148.887 3.343 2.385C14.233 6.005 16 6 16 6H0z"/>
<path class="svg-content" d="m0 7s2 0 5-4c1-1 2-2 3-2 1 0 2 1 3 2 3 4 5 4 5 4h-16z"/> <path class="svg-content" d="m0 7s2 0 5-4c1-1 2-2 3-2 1 0 2 1 3 2 3 4 5 4 5 4h-16z"/>
</symbol> </symbol>
<symbol id='discourse-other-tab' viewBox="0 0 114 113">
<g clip-path="url(#clip0_2925_742)">
<rect x="8" y="8" width="44" height="44" rx="5"/>
<rect x="8" y="61" width="44" height="44" rx="5"/>
<rect x="62" y="61" width="44" height="44" rx="5"/>
<rect width="44" height="43.9967" rx="5" transform="matrix(0.705436 -0.708774 0.705436 0.708774 53 30)"/>
</g>
<defs>
<clipPath id="clip0_2925_742">
<rect width="114" height="113"/>
</clipPath>
</defs>
</symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB