FEATURE: Site setting to display user avatars in user menu (#24514)

This commit is contained in:
Mark VanLandingham 2023-12-07 11:30:44 -06:00 committed by GitHub
parent e4c373194d
commit ee05f57e2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 314 additions and 15 deletions

View File

@ -73,7 +73,11 @@ export default class UserMenuBookmarksList extends UserMenuNotificationsList {
await Bookmark.applyTransformations(bookmarks);
content.push(
...bookmarks.map((bookmark) => {
return new UserMenuBookmarkItem({ bookmark });
return new UserMenuBookmarkItem({
bookmark,
siteSettings: this.siteSettings,
site: this.site,
});
})
);

View File

@ -0,0 +1,12 @@
import Component from "@glimmer/component";
import avatar from "discourse/helpers/bound-avatar-template";
import dIcon from "discourse-common/helpers/d-icon";
export default class IconAvatar extends Component {
<template>
<div class="icon-avatar">
{{avatar @data.avatarTemplate "small"}}
{{dIcon @data.icon}}
</div>
</template>
}

View File

@ -4,7 +4,11 @@
title={{this.linkTitle}}
{{on "click" this.onClick}}
>
{{d-icon this.icon}}
{{#if this.iconComponent}}
<this.iconComponent @data={{this.iconComponentArgs}} />
{{else}}
{{d-icon this.icon}}
{{/if}}
<div>
{{#if this.label}}
<span class={{concat "item-label " this.labelClass}}>

View File

@ -49,6 +49,14 @@ export default class UserMenuItem extends Component {
return this.#item.topicId;
}
get iconComponent() {
return this.#item.iconComponent;
}
get iconComponentArgs() {
return this.#item.iconComponentArgs;
}
get #item() {
return this.args.item;
}

View File

@ -1,5 +1,5 @@
<div
class="user-menu revamped menu-panel drop-down"
class={{this.classNames}}
data-max-width="320"
{{did-insert this.triggerRenderedAppEvent}}
>

View File

@ -196,6 +196,14 @@ export default class UserMenu extends Component {
@tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT;
@tracked currentNotificationTypes;
get classNames() {
let classes = ["user-menu", "revamped", "menu-panel", "drop-down"];
if (this.siteSettings.show_user_menu_avatars) {
classes.push("show-avatars");
}
return classes.join(" ");
}
@cached
get topTabs() {
const tabs = [];

View File

@ -77,6 +77,16 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
const topics = data.topics.map((t) => this.store.createRecord("topic", t));
await Topic.applyTransformations(topics);
if (this.siteSettings.show_user_menu_avatars) {
// Populate avatar_template for lastPoster
const usersById = new Map(data.users.map((u) => [u.id, u]));
topics.forEach((t) => {
t.last_poster_avatar_template = usersById.get(
t.lastPoster.user_id
).avatar_template;
});
}
const readNotifications = await Notification.initializeNotifications(
data.read_notifications
);
@ -96,7 +106,13 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
})
);
} else {
content.push(new UserMenuMessageItem({ message: item }));
content.push(
new UserMenuMessageItem({
message: item,
siteSettings: this.siteSettings,
site: this.site,
})
);
}
});

View File

@ -1,7 +1,12 @@
import UserMenuIconAvatar from "discourse/components/user-menu/icon-avatar";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL from "discourse/lib/url";
export default class UserMenuBaseItem {
constructor({ siteSettings, site }) {
this.site = site;
this.siteSettings = siteSettings;
}
get className() {}
get linkHref() {
@ -30,6 +35,20 @@ export default class UserMenuBaseItem {
get topicId() {}
get avatarTemplate() {}
get iconComponent() {
return this.siteSettings.show_user_menu_avatars ? UserMenuIconAvatar : null;
}
get iconComponentArgs() {
return {
avatarTemplate:
this.avatarTemplate || this.site.system_user_avatar_template,
icon: this.icon,
};
}
onClick({ event, closeUserMenu }) {
if (wantsNewWindow(event)) {
return;

View File

@ -34,4 +34,8 @@ export default class UserMenuBookmarkItem extends UserMenuBaseItem {
get topicId() {
return this.bookmark.topic_id;
}
get avatarTemplate() {
return this.bookmark.user.avatar_template;
}
}

View File

@ -41,4 +41,8 @@ export default class UserMenuMessageItem extends UserMenuBaseItem {
get topicId() {
return this.message.id;
}
get avatarTemplate() {
return this.message.last_poster_avatar_template;
}
}

View File

@ -58,6 +58,10 @@ export default class UserMenuNotificationItem extends UserMenuBaseItem {
return this.notification.topic_id;
}
get avatarTemplate() {
return this.notification.acting_user_avatar_template;
}
get #notificationName() {
return this.site.notificationLookup[this.notification.notification_type];
}

View File

@ -1101,3 +1101,36 @@ acceptance("User menu - Dismiss button", function (needs) {
);
});
});
acceptance("User menu - avatars", function (needs) {
needs.user();
needs.settings({
show_user_menu_avatars: true,
});
test("It shows user avatars for various notifications on all notifications pane", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
assert.ok(exists("li.notification.edited .icon-avatar"));
assert.ok(exists("li.notification.replied .icon-avatar"));
});
test("It shows user avatars for messages", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-messages");
assert.ok(exists("li.notification.private-message .icon-avatar"));
assert.ok(exists("li.message .icon-avatar"));
});
test("It shows user avatars for bookmark items and bookmark reminder notification items", async function (assert) {
await visit("/");
await click(".d-header-icons .current-user");
await click("#user-menu-button-bookmarks");
assert.ok(exists("li.notification.bookmark-reminder .icon-avatar"));
assert.ok(exists("li.bookmark .icon-avatar"));
});
});

View File

@ -6,6 +6,7 @@ export default {
{
id: 5340,
notification_type: NOTIFICATION_TYPES.edited,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
post_number: 1,
topic_id: 130,
@ -23,6 +24,7 @@ export default {
{
id: 123,
notification_type: NOTIFICATION_TYPES.replied,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
post_number: 1,
topic_id: 1234,
@ -33,12 +35,14 @@ export default {
{
id: 456,
notification_type: NOTIFICATION_TYPES.liked_consolidated,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
data: { display_username: "aquaman", count: "5" },
},
{
id: 789,
notification_type: NOTIFICATION_TYPES.group_message_summary,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
post_number: null,
topic_id: null,
@ -53,6 +57,7 @@ export default {
{
id: 1234,
notification_type: NOTIFICATION_TYPES.invitee_accepted,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
post_number: null,
topic_id: null,
@ -62,6 +67,7 @@ export default {
{
id: 5678,
notification_type: NOTIFICATION_TYPES.membership_request_accepted,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
post_number: null,
topic_id: null,

View File

@ -5,6 +5,7 @@ export default {
id: 1713,
user_id: 1,
notification_type: 24,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
high_priority: true,
created_at: "2022-08-05T17:27:24.873Z",
@ -69,6 +70,7 @@ export default {
id: 8315,
user_id: 1,
notification_type: 6,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false,
high_priority: true,
created_at: "2022-08-05T17:27:24.873Z",
@ -139,5 +141,9 @@ export default {
}
],
read_notifications: [],
users: [{id: 13,
username: "mixtape",
avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png"
}]
}
}

View File

@ -400,7 +400,7 @@ module(
}
);
function getMessage(overrides = {}) {
function getMessage(overrides = {}, siteSettings, site) {
const message = deepMerge(
cloneJSON(
PrivateMessagesFixture["/topics/private-messages/eviltrout.json"]
@ -409,7 +409,7 @@ function getMessage(overrides = {}) {
overrides
);
return new UserMenuMessageItem({ message });
return new UserMenuMessageItem({ message, siteSettings, site });
}
module(
@ -422,7 +422,11 @@ module(
test("item description is the fancy title of the message", async function (assert) {
this.set(
"item",
getMessage({ fancy_title: "This is a <b>safe</b> title!" })
getMessage(
{ fancy_title: "This is a <b>safe</b> title!" },
this.siteSettings,
this.site
)
);
await render(template);
assert.strictEqual(
@ -438,7 +442,7 @@ module(
}
);
function getBookmark(overrides = {}) {
function getBookmark(overrides = {}, siteSettings, site) {
const bookmark = deepMerge(
{
id: 6,
@ -480,7 +484,7 @@ function getBookmark(overrides = {}) {
overrides
);
return new UserMenuBookmarkItem({ bookmark });
return new UserMenuBookmarkItem({ bookmark, siteSettings, site });
}
module(
@ -491,7 +495,7 @@ module(
const template = hbs`<UserMenu::MenuItem @item={{this.item}}/>`;
test("uses bookmarkable_url for the href", async function (assert) {
this.set("item", getBookmark());
this.set("item", getBookmark({}, this.siteSettings, this.site));
await render(template);
assert.ok(
query("li.bookmark a").href.endsWith("/t/this-bookmarkable-url/227/1")
@ -501,7 +505,11 @@ module(
test("item label is the bookmarked post author", async function (assert) {
this.set(
"item",
getBookmark({ user: { username: "bookmarkPostAuthor" } })
getBookmark(
{ user: { username: "bookmarkPostAuthor" } },
this.siteSettings,
this.site
)
);
await render(template);
assert.strictEqual(
@ -511,7 +519,14 @@ module(
});
test("item description is the bookmark title", async function (assert) {
this.set("item", getBookmark({ title: "Custom bookmark title" }));
this.set(
"item",
getBookmark(
{ title: "Custom bookmark title" },
this.siteSettings,
this.site
)
);
await render(template);
assert.strictEqual(
query("li.bookmark .item-description").textContent.trim(),

View File

@ -514,6 +514,7 @@
padding: 0;
align-self: flex-start;
width: 100%;
.d-icon {
padding-top: 0.2em;
margin-right: 0.5em;
@ -557,6 +558,42 @@
}
}
.user-menu.show-avatars {
li {
a {
.icon-avatar {
display: flex;
position: relative;
overflow: visible;
margin-right: 0.5em;
flex-shrink: 0;
width: 2.25em;
height: 2.25em;
padding: 0.125em 0;
.avatar {
width: 100%;
height: 100%;
}
.d-icon {
display: block;
position: absolute;
right: -0.675em;
bottom: -0.125em;
background: var(--secondary);
color: var(--primary-very-high);
padding: 0.25em;
border-radius: 100%;
font-size: var(--font-down-1);
}
}
& + div {
padding: 0.25em 0;
}
}
}
}
.hamburger-panel .menu-panel.slide-in {
left: 0;

View File

@ -57,6 +57,8 @@ class NotificationsController < ApplicationController
notifications =
Notification.filter_inaccessible_topic_notifications(current_user.guardian, notifications)
notifications =
Notification.populate_acting_user(notifications) if SiteSetting.show_user_menu_avatars
json = {
notifications: serialize_data(notifications, NotificationSerializer),
@ -86,6 +88,8 @@ class NotificationsController < ApplicationController
notifications = notifications.offset(offset).limit(limit)
notifications =
Notification.filter_inaccessible_topic_notifications(current_user.guardian, notifications)
notifications =
Notification.populate_acting_user(notifications) if SiteSetting.show_user_menu_avatars
render_json_dump(
notifications: serialize_data(notifications, NotificationSerializer),
total_rows_notifications: total_rows,

View File

@ -1897,6 +1897,9 @@ class UsersController < ApplicationController
end
if reminder_notifications.present?
if SiteSetting.show_user_menu_avatars
Notification.populate_acting_user(reminder_notifications)
end
serialized_notifications =
ActiveModel::ArraySerializer.new(
reminder_notifications,
@ -1967,6 +1970,7 @@ class UsersController < ApplicationController
end
if unread_notifications.present?
Notification.populate_acting_user(unread_notifications) if SiteSetting.show_user_menu_avatars
serialized_unread_notifications =
ActiveModel::ArraySerializer.new(
unread_notifications,
@ -1978,9 +1982,17 @@ class UsersController < ApplicationController
if messages_list
serialized_messages =
serialize_data(messages_list, TopicListSerializer, scope: guardian, root: false)[:topics]
serialized_users =
if SiteSetting.show_user_menu_avatars
users = messages_list.topics.map { |t| t.posters.last.user }.flatten.compact.uniq(&:id)
serialize_data(users, BasicUserSerializer, scope: guardian, root: false)
else
[]
end
end
if read_notifications.present?
Notification.populate_acting_user(read_notifications) if SiteSetting.show_user_menu_avatars
serialized_read_notifications =
ActiveModel::ArraySerializer.new(
read_notifications,
@ -1993,6 +2005,7 @@ class UsersController < ApplicationController
unread_notifications: serialized_unread_notifications || [],
read_notifications: serialized_read_notifications || [],
topics: serialized_messages || [],
users: serialized_users || [],
}
end

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true
class Notification < ActiveRecord::Base
attr_accessor :acting_user
attr_accessor :acting_username
belongs_to :user
belongs_to :topic
@ -349,6 +352,25 @@ class Notification < ActiveRecord::Base
end
end
def self.populate_acting_user(notifications)
usernames =
notifications.map do |notification|
notification.acting_username =
(
notification.data_hash[:username] || notification.data_hash[:display_username] ||
notification.data_hash[:mentioned_by_username] ||
notification.data_hash[:invited_by_username]
)&.downcase
end
users = User.where(username_lower: usernames.uniq).index_by(&:username_lower)
notifications.each do |notification|
notification.acting_user = users[notification.acting_username]
end
notifications
end
def unread_high_priority?
self.high_priority? && !read
end

View File

@ -13,7 +13,8 @@ class NotificationSerializer < ApplicationSerializer
:fancy_title,
:slug,
:data,
:is_warning
:is_warning,
:acting_user_avatar_template
def slug
Slug.for(object.topic.title) if object.topic.present?
@ -46,4 +47,12 @@ class NotificationSerializer < ApplicationSerializer
def include_external_id?
SiteSetting.enable_discourse_connect
end
def acting_user_avatar_template
object.acting_user.avatar_template_url
end
def include_acting_user_avatar_template?
object.acting_user.present?
end
end

View File

@ -46,6 +46,7 @@ class SiteSerializer < ApplicationSerializer
:denied_emojis,
:tos_url,
:privacy_policy_url,
:system_user_avatar_template,
)
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@ -332,6 +333,14 @@ class SiteSerializer < ApplicationSerializer
privacy_policy_url.present?
end
def system_user_avatar_template
Discourse.system_user.avatar_template
end
def include_system_user_avatar_template?
SiteSetting.show_user_menu_avatars
end
private
def ordered_flags(flags)

View File

@ -2484,6 +2484,7 @@ en:
experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
page_loading_indicator: "Configure the loading indicator which appears during page navigations within Discourse. 'Spinner' is a full page indicator. 'Slider' shows a narrow bar at the top of the screen."
show_user_menu_avatars: "Show user avatars in the user menu"
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."

View File

@ -395,7 +395,9 @@ basic:
choices:
- spinner
- slider
show_user_menu_avatars:
client: true
default: false
login:
invite_only:
refresh: true

View File

@ -806,4 +806,34 @@ RSpec.describe Notification do
expect(notification.shelved_notification).to be_nil
end
end
describe ".populate_acting_user" do
fab!(:user1) { Fabricate(:user) }
fab!(:user2) { Fabricate(:user) }
fab!(:user3) { Fabricate(:user) }
fab!(:user4) { Fabricate(:user) }
fab!(:notification1) do
Fabricate(:notification, user: user, data: { username: user1.username }.to_json)
end
fab!(:notification2) do
Fabricate(:notification, user: user, data: { display_username: user2.username }.to_json)
end
fab!(:notification3) do
Fabricate(:notification, user: user, data: { mentioned_by_username: user3.username }.to_json)
end
fab!(:notification4) do
Fabricate(:notification, user: user, data: { invited_by_username: user4.username }.to_json)
end
it "Sets the acting_user correctly for each notification" do
Notification.populate_acting_user(
[notification1, notification2, notification3, notification4],
)
expect(notification1.acting_user).to eq(user1)
expect(notification2.acting_user).to eq(user2)
expect(notification3.acting_user).to eq(user3)
expect(notification4.acting_user).to eq(user4)
end
end
end

View File

@ -32,7 +32,10 @@ RSpec.describe NotificationsController do
context "when logged in" do
context "as normal user" do
fab!(:user) { sign_in(Fabricate(:user)) }
fab!(:notification) { Fabricate(:notification, user: user) }
fab!(:acting_user) { Fabricate(:user) }
fab!(:notification) do
Fabricate(:notification, user: user, data: { username: acting_user.username }.to_json)
end
describe "#index" do
it "should succeed for recent" do
@ -424,6 +427,20 @@ RSpec.describe NotificationsController do
end
end
end
context "with `show_user_menu_avatars` setting enabled" do
before { SiteSetting.show_user_menu_avatars = true }
it "serializes acting_user_avatar_template into notifications" do
get "/notifications.json"
notifications = response.parsed_body["notifications"]
expect(notifications).not_to be_empty
notifications.each do |notification|
expect(notification["acting_user_avatar_template"]).to be_present
end
end
end
end
it "should succeed" do

View File

@ -7051,6 +7051,18 @@ RSpec.describe UsersController do
expect(notifications.size).to eq(1)
expect(notifications.first["data"]["bookmark_id"]).to eq(bookmark_with_reminder.id)
end
context "with `show_user_menu_avatars` setting enabled" do
before { SiteSetting.show_user_menu_avatars = true }
it "serializes acting_user_avatar into notifications" do
get "/u/#{user.username}/user-menu-bookmarks"
expect(response.status).to eq(200)
first_notification = response.parsed_body["notifications"].first
expect(first_notification["acting_user_avatar_template"]).to be_present
end
end
end
end