Skip to content

UX: enhances messages dropdown with unread count #33889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";

export default class MessagesDropdown extends Component {
@service currentUser;

get currentSelection() {
return this.args.content.find((item) => item.id === this.args.value);
}

get showUnreadIcon() {
return (
this.currentUser?.use_experimental_sidebar_messages_count &&
!this.currentUser?.sidebarShowCountOfNewItems
);
}

@action
onRegisterApi(api) {
this.menuApi = api;
}

@action
openInbox(id) {
this.args.onChange(id);
this.menuApi.close();
}

<template>
<DMenu
@icon={{this.currentSelection.icon}}
@label={{this.currentSelection.name}}
@title={{i18n "user.messages.all"}}
@identifier="messages-dropdown"
@onRegisterApi={{this.onRegisterApi}}
>
<:trigger>
{{#if this.currentSelection.showUnreadIcon}}
{{icon "circle" class="d-icon-d-unread"}}
{{/if}}
{{icon "angle-down"}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
{{#each @content as |item|}}
<dropdown.item>
<DButton
@translatedLabel={{item.name}}
@icon={{item.icon}}
class={{if
(eq this.currentSelection.name item.name)
"is-selected"
}}
@action={{this.openInbox item.id}}
>
{{#if item.showUnreadIcon}}
{{icon "circle" class="d-icon-d-unread"}}
{{/if}}
</DButton>
</dropdown.item>
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { alias, and, equal, readOnly } from "@ember/object/computed";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DiscourseURL from "discourse/lib/url";
import { i18n } from "discourse-i18n";

Expand All @@ -25,6 +26,8 @@ export function resetCustomUserNavMessagesDropdownRows() {
}

export default class extends Controller {
@service currentUser;
@service pmTopicTrackingState;
@service router;
@controller user;
@controller userTopicsList;
Expand Down Expand Up @@ -64,26 +67,66 @@ export default class extends Controller {
return value;
}

get showUnread() {
return this.currentUser?.use_experimental_sidebar_messages_count;
}

get showCount() {
return this.showUnread && this.currentUser?.sidebarShowCountOfNewItems;
}

@cached
get messagesDropdownContent() {
const usernameLower = this.model.username_lower;

let inboxName = i18n("user.messages.inbox");
let userMsgsCount = 0;
if (this.showUnread) {
userMsgsCount = ["new", "unread"].reduce((count, type) => {
return (
count +
this.pmTopicTrackingState.lookupCount(type, {
inboxFilter: "user",
})
);
}, userMsgsCount);
if (userMsgsCount && this.showCount) {
inboxName = htmlSafe(`${inboxName}&nbsp;(${userMsgsCount})`);
}
}
const content = [
{
id: this.router.urlFor("userPrivateMessages.user", usernameLower),
name: i18n("user.messages.inbox"),
name: inboxName,
showUnreadIcon: !!userMsgsCount && !this.showCount,
},
];

this.model.groupsWithMessages.forEach((group) => {
this.model.groupsWithMessages.forEach(({ name }) => {
let groupName = name;
let groupMsgsCount = 0;
if (this.showUnread) {
groupMsgsCount = ["new", "unread"].reduce((count, type) => {
return (
count +
this.pmTopicTrackingState.lookupCount(type, {
inboxFilter: "group",
groupName: name,
})
);
}, groupMsgsCount);
if (groupMsgsCount && this.showCount) {
groupName = htmlSafe(`${name}&nbsp;(${groupMsgsCount})`);
}
}
content.push({
id: this.router.urlFor(
"userPrivateMessages.group",
usernameLower,
group.name
name
),
name: group.name,
name: groupName,
icon: "inbox",
showUnreadIcon: !!groupMsgsCount && !this.showCount,
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
import { click, currentURL, fillIn, findAll, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { resetCustomUserNavMessagesDropdownRows } from "discourse/controllers/user-private-messages";
import { NotificationLevels } from "discourse/lib/notification-levels";
Expand Down Expand Up @@ -111,6 +111,7 @@ function withGroupMessagesSetup(needs) {

[
"/topics/private-messages-new/:username.json",
"/topics/private-messages-sent/:username.json",
"/topics/private-messages-unread/:username.json",
"/topics/private-messages-archive/:username.json",
"/topics/private-messages-group/:username/:group_name/new.json",
Expand Down Expand Up @@ -598,68 +599,71 @@ acceptance(

test("navigating between user messages route with dropdown", async function (assert) {
await visit("/u/Charlie/messages");
assert
.dom(".messages-dropdown-trigger")
.hasText(
i18n("user.messages.inbox"),
"User personal inbox is selected in dropdown"
);

const messagesDropdown = selectKit(".user-nav-messages-dropdown");

assert.strictEqual(
messagesDropdown.header().name(),
i18n("user.messages.inbox"),
"User personal inbox is selected in dropdown"
await click(
".user-nav__messages-sent a[href='/u/Charlie/messages/sent']"
);

await click(".user-nav__messages-sent");

assert.strictEqual(
messagesDropdown.header().name(),
i18n("user.messages.inbox"),
"User personal inbox is still selected when viewing sent messages"
currentURL(),
"/u/Charlie/messages/sent",
"routes to the right URL when clicking sent messages"
);
assert
.dom(".messages-dropdown-trigger")
.hasText(
i18n("user.messages.inbox"),
"User personal inbox is still selected when viewing sent messages"
);

await messagesDropdown.expand();
await messagesDropdown.selectRowByName("awesome_group");
await click(".messages-dropdown-trigger");
const options = findAll(".dropdown-menu__item");

await click(options[1].querySelector(".btn"));
assert.strictEqual(
currentURL(),
"/u/charlie/messages/group/awesome_group",
"routes to the right URL when selecting awesome_group in the dropdown"
);
assert
.dom(".messages-dropdown-trigger")
.hasText("awesome_group", "Group inbox is selected in dropdown");

assert.strictEqual(
messagesDropdown.header().name(),
"awesome_group",
"Group inbox is selected in dropdown"
);

await click(".user-nav__messages-group-new");

assert.strictEqual(
messagesDropdown.header().name(),
"awesome_group",
"Group inbox is still selected in dropdown"
await click(
".user-nav__messages-group-new a[href='/u/charlie/messages/group/awesome_group/new']"
);
assert
.dom(".messages-dropdown-trigger")
.hasText("awesome_group", "Group inbox is still selected in dropdown");

await messagesDropdown.expand();
await messagesDropdown.selectRowByName(i18n("user.messages.tags"));
await click(".messages-dropdown-trigger");
const options2 = findAll(".dropdown-menu__item");

await click(options2[2].querySelector(".btn"));
assert.strictEqual(
currentURL(),
"/u/charlie/messages/tags",
"routes to the right URL when selecting tags in the dropdown"
);

assert.strictEqual(
messagesDropdown.header().name(),
i18n("user.messages.tags"),
"All tags is selected in dropdown"
);
assert
.dom(".messages-dropdown-trigger")
.hasText(
i18n("user.messages.tags"),
"All tags is selected in dropdown"
);

await click(".discourse-tag[data-tag-name='tag1']");

assert.strictEqual(
messagesDropdown.header().name(),
i18n("user.messages.tags"),
"All tags is still selected in dropdown"
);
assert
.dom(".messages-dropdown-trigger")
.hasText(
i18n("user.messages.tags"),
"All tags is still selected in dropdown"
);
});

test("addUserMessagesNavigationDropdownRow plugin api", async function (assert) {
Expand All @@ -673,14 +677,18 @@ acceptance(
});

await visit("/u/eviltrout/messages");
await click(".messages-dropdown-trigger");

const messagesDropdown = selectKit(".user-nav-messages-dropdown");
await messagesDropdown.expand();
const options = findAll(".dropdown-menu__item");
assert.dom(options[2]).hasText("test nav");
assert.dom(options[2].querySelector(".d-icon-arrow-left")).exists();

const row = messagesDropdown.rowByName("test nav");

assert.strictEqual(row.value(), "/u/eviltrout/preferences");
assert.dom(row.icon()).hasClass("d-icon-arrow-left");
await click(options[2].querySelector(".btn"));
assert.strictEqual(
currentURL(),
"/u/eviltrout/preferences/account",
"navigates to the preferences page when clicking on the custom row"
);
} finally {
resetCustomUserNavMessagesDropdownRows();
}
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/common/base/d-icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
}

.d-icon.d-icon-d-tracking,
.d-icon.d-icon-d-unread,
.d-icon.d-icon-d-watching {
color: var(--tertiary);
}
43 changes: 43 additions & 0 deletions app/assets/stylesheets/common/base/new-user.scss
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,49 @@
}
}

%unread-icon {
font-size: var(--font-down-4);
margin: 0 0.375rem;
color: var(--tertiary);
}

.user-messages-page {
.messages-dropdown-trigger {
.d-icon-d-unread {
@extend %unread-icon;
}

.btn:hover {
.d-icon-d-unread {
color: var(--tertiary);
}
}

.d-icon-angle-down {
margin-right: 0;
}
}

.messages-dropdown-content {
.dropdown-menu__item {
.d-icon-d-unread {
@extend %unread-icon;
}

.btn:hover {
.d-icon-d-unread {
color: var(--tertiary);
}
}

.btn.is-selected {
background: var(--d-selected);
color: var(--primary);
}
}
}
}

@include viewport.until(sm) {
.user-content {
margin-top: 1em;
Expand Down
Loading
Loading