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); await Bookmark.applyTransformations(bookmarks);
content.push( content.push(
...bookmarks.map((bookmark) => { ...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}} title={{this.linkTitle}}
{{on "click" this.onClick}} {{on "click" this.onClick}}
> >
{{d-icon this.icon}} {{#if this.iconComponent}}
<this.iconComponent @data={{this.iconComponentArgs}} />
{{else}}
{{d-icon this.icon}}
{{/if}}
<div> <div>
{{#if this.label}} {{#if this.label}}
<span class={{concat "item-label " this.labelClass}}> <span class={{concat "item-label " this.labelClass}}>

View File

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

View File

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

View File

@ -196,6 +196,14 @@ export default class UserMenu extends Component {
@tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT; @tracked currentPanelComponent = DEFAULT_PANEL_COMPONENT;
@tracked currentNotificationTypes; @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 @cached
get topTabs() { get topTabs() {
const tabs = []; 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)); const topics = data.topics.map((t) => this.store.createRecord("topic", t));
await Topic.applyTransformations(topics); 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( const readNotifications = await Notification.initializeNotifications(
data.read_notifications data.read_notifications
); );
@ -96,7 +106,13 @@ export default class UserMenuMessagesList extends UserMenuNotificationsList {
}) })
); );
} else { } 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 { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
export default class UserMenuBaseItem { export default class UserMenuBaseItem {
constructor({ siteSettings, site }) {
this.site = site;
this.siteSettings = siteSettings;
}
get className() {} get className() {}
get linkHref() { get linkHref() {
@ -30,6 +35,20 @@ export default class UserMenuBaseItem {
get topicId() {} 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 }) { onClick({ event, closeUserMenu }) {
if (wantsNewWindow(event)) { if (wantsNewWindow(event)) {
return; return;

View File

@ -34,4 +34,8 @@ export default class UserMenuBookmarkItem extends UserMenuBaseItem {
get topicId() { get topicId() {
return this.bookmark.topic_id; 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() { get topicId() {
return this.message.id; 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; return this.notification.topic_id;
} }
get avatarTemplate() {
return this.notification.acting_user_avatar_template;
}
get #notificationName() { get #notificationName() {
return this.site.notificationLookup[this.notification.notification_type]; 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, id: 5340,
notification_type: NOTIFICATION_TYPES.edited, notification_type: NOTIFICATION_TYPES.edited,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
post_number: 1, post_number: 1,
topic_id: 130, topic_id: 130,
@ -23,6 +24,7 @@ export default {
{ {
id: 123, id: 123,
notification_type: NOTIFICATION_TYPES.replied, notification_type: NOTIFICATION_TYPES.replied,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
post_number: 1, post_number: 1,
topic_id: 1234, topic_id: 1234,
@ -33,12 +35,14 @@ export default {
{ {
id: 456, id: 456,
notification_type: NOTIFICATION_TYPES.liked_consolidated, notification_type: NOTIFICATION_TYPES.liked_consolidated,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
data: { display_username: "aquaman", count: "5" }, data: { display_username: "aquaman", count: "5" },
}, },
{ {
id: 789, id: 789,
notification_type: NOTIFICATION_TYPES.group_message_summary, notification_type: NOTIFICATION_TYPES.group_message_summary,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
post_number: null, post_number: null,
topic_id: null, topic_id: null,
@ -53,6 +57,7 @@ export default {
{ {
id: 1234, id: 1234,
notification_type: NOTIFICATION_TYPES.invitee_accepted, notification_type: NOTIFICATION_TYPES.invitee_accepted,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
post_number: null, post_number: null,
topic_id: null, topic_id: null,
@ -62,6 +67,7 @@ export default {
{ {
id: 5678, id: 5678,
notification_type: NOTIFICATION_TYPES.membership_request_accepted, notification_type: NOTIFICATION_TYPES.membership_request_accepted,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
post_number: null, post_number: null,
topic_id: null, topic_id: null,

View File

@ -5,6 +5,7 @@ export default {
id: 1713, id: 1713,
user_id: 1, user_id: 1,
notification_type: 24, notification_type: 24,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
high_priority: true, high_priority: true,
created_at: "2022-08-05T17:27:24.873Z", created_at: "2022-08-05T17:27:24.873Z",
@ -69,6 +70,7 @@ export default {
id: 8315, id: 8315,
user_id: 1, user_id: 1,
notification_type: 6, notification_type: 6,
acting_user_avatar_template: "/letter_avatar_proxy/v4/letter/o/f05b48/{size}.png",
read: false, read: false,
high_priority: true, high_priority: true,
created_at: "2022-08-05T17:27:24.873Z", created_at: "2022-08-05T17:27:24.873Z",
@ -139,5 +141,9 @@ export default {
} }
], ],
read_notifications: [], 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( const message = deepMerge(
cloneJSON( cloneJSON(
PrivateMessagesFixture["/topics/private-messages/eviltrout.json"] PrivateMessagesFixture["/topics/private-messages/eviltrout.json"]
@ -409,7 +409,7 @@ function getMessage(overrides = {}) {
overrides overrides
); );
return new UserMenuMessageItem({ message }); return new UserMenuMessageItem({ message, siteSettings, site });
} }
module( module(
@ -422,7 +422,11 @@ module(
test("item description is the fancy title of the message", async function (assert) { test("item description is the fancy title of the message", async function (assert) {
this.set( this.set(
"item", "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); await render(template);
assert.strictEqual( assert.strictEqual(
@ -438,7 +442,7 @@ module(
} }
); );
function getBookmark(overrides = {}) { function getBookmark(overrides = {}, siteSettings, site) {
const bookmark = deepMerge( const bookmark = deepMerge(
{ {
id: 6, id: 6,
@ -480,7 +484,7 @@ function getBookmark(overrides = {}) {
overrides overrides
); );
return new UserMenuBookmarkItem({ bookmark }); return new UserMenuBookmarkItem({ bookmark, siteSettings, site });
} }
module( module(
@ -491,7 +495,7 @@ module(
const template = hbs`<UserMenu::MenuItem @item={{this.item}}/>`; const template = hbs`<UserMenu::MenuItem @item={{this.item}}/>`;
test("uses bookmarkable_url for the href", async function (assert) { 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); await render(template);
assert.ok( assert.ok(
query("li.bookmark a").href.endsWith("/t/this-bookmarkable-url/227/1") 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) { test("item label is the bookmarked post author", async function (assert) {
this.set( this.set(
"item", "item",
getBookmark({ user: { username: "bookmarkPostAuthor" } }) getBookmark(
{ user: { username: "bookmarkPostAuthor" } },
this.siteSettings,
this.site
)
); );
await render(template); await render(template);
assert.strictEqual( assert.strictEqual(
@ -511,7 +519,14 @@ module(
}); });
test("item description is the bookmark title", async function (assert) { 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); await render(template);
assert.strictEqual( assert.strictEqual(
query("li.bookmark .item-description").textContent.trim(), query("li.bookmark .item-description").textContent.trim(),

View File

@ -514,6 +514,7 @@
padding: 0; padding: 0;
align-self: flex-start; align-self: flex-start;
width: 100%; width: 100%;
.d-icon { .d-icon {
padding-top: 0.2em; padding-top: 0.2em;
margin-right: 0.5em; 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 { .hamburger-panel .menu-panel.slide-in {
left: 0; left: 0;

View File

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

View File

@ -1897,6 +1897,9 @@ class UsersController < ApplicationController
end end
if reminder_notifications.present? if reminder_notifications.present?
if SiteSetting.show_user_menu_avatars
Notification.populate_acting_user(reminder_notifications)
end
serialized_notifications = serialized_notifications =
ActiveModel::ArraySerializer.new( ActiveModel::ArraySerializer.new(
reminder_notifications, reminder_notifications,
@ -1967,6 +1970,7 @@ class UsersController < ApplicationController
end end
if unread_notifications.present? if unread_notifications.present?
Notification.populate_acting_user(unread_notifications) if SiteSetting.show_user_menu_avatars
serialized_unread_notifications = serialized_unread_notifications =
ActiveModel::ArraySerializer.new( ActiveModel::ArraySerializer.new(
unread_notifications, unread_notifications,
@ -1978,9 +1982,17 @@ class UsersController < ApplicationController
if messages_list if messages_list
serialized_messages = serialized_messages =
serialize_data(messages_list, TopicListSerializer, scope: guardian, root: false)[:topics] 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 end
if read_notifications.present? if read_notifications.present?
Notification.populate_acting_user(read_notifications) if SiteSetting.show_user_menu_avatars
serialized_read_notifications = serialized_read_notifications =
ActiveModel::ArraySerializer.new( ActiveModel::ArraySerializer.new(
read_notifications, read_notifications,
@ -1993,6 +2005,7 @@ class UsersController < ApplicationController
unread_notifications: serialized_unread_notifications || [], unread_notifications: serialized_unread_notifications || [],
read_notifications: serialized_read_notifications || [], read_notifications: serialized_read_notifications || [],
topics: serialized_messages || [], topics: serialized_messages || [],
users: serialized_users || [],
} }
end end

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Notification < ActiveRecord::Base class Notification < ActiveRecord::Base
attr_accessor :acting_user
attr_accessor :acting_username
belongs_to :user belongs_to :user
belongs_to :topic belongs_to :topic
@ -349,6 +352,25 @@ class Notification < ActiveRecord::Base
end end
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? def unread_high_priority?
self.high_priority? && !read self.high_priority? && !read
end end

View File

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

View File

@ -46,6 +46,7 @@ class SiteSerializer < ApplicationSerializer
:denied_emojis, :denied_emojis,
:tos_url, :tos_url,
:privacy_policy_url, :privacy_policy_url,
:system_user_avatar_template,
) )
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@ -332,6 +333,14 @@ class SiteSerializer < ApplicationSerializer
privacy_policy_url.present? privacy_policy_url.present?
end 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 private
def ordered_flags(flags) 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>." 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." 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: errors:
invalid_css_color: "Invalid color. Enter a color name or hex value." invalid_css_color: "Invalid color. Enter a color name or hex value."

View File

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

View File

@ -806,4 +806,34 @@ RSpec.describe Notification do
expect(notification.shelved_notification).to be_nil expect(notification.shelved_notification).to be_nil
end end
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 end

View File

@ -32,7 +32,10 @@ RSpec.describe NotificationsController do
context "when logged in" do context "when logged in" do
context "as normal user" do context "as normal user" do
fab!(:user) { sign_in(Fabricate(:user)) } 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 describe "#index" do
it "should succeed for recent" do it "should succeed for recent" do
@ -424,6 +427,20 @@ RSpec.describe NotificationsController do
end end
end 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 end
it "should succeed" do it "should succeed" do

View File

@ -7051,6 +7051,18 @@ RSpec.describe UsersController do
expect(notifications.size).to eq(1) expect(notifications.size).to eq(1)
expect(notifications.first["data"]["bookmark_id"]).to eq(bookmark_with_reminder.id) expect(notifications.first["data"]["bookmark_id"]).to eq(bookmark_with_reminder.id)
end 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
end end