DEV: Introduce a basic version of the new notifications menu behind a feature flag (#17492)
This commit is contained in:
parent
9028df0fda
commit
fac04f3e73
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
<div class="empty-state">
|
||||
<span class="empty-state-title">
|
||||
{{i18n "user_menu.generic_no_items"}}
|
||||
</span>
|
||||
</div>
|
|
@ -0,0 +1,31 @@
|
|||
{{#if this.loading}}
|
||||
<div class="spinner-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{{else if this.items.length}}
|
||||
<ul>
|
||||
{{#each this.items as |item|}}
|
||||
<item.userMenuComponent @item={{item}}/>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="panel-body-bottom">
|
||||
{{#if this.showAll}}
|
||||
<a class="btn btn-default btn-icon no-text show-all" href={{this.showAllHref}} title={{this.showAllTitle}}>
|
||||
{{d-icon "chevron-down" aria-label=this.showAllTitle}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.showDismiss}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-default notifications-dismiss btn-icon-text"
|
||||
title={{this.dismissTitle}}
|
||||
{{on "click" this.dismissButtonClick}}
|
||||
>
|
||||
{{d-icon "check"}}
|
||||
{{i18n "user.dismiss"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
<this.emptyStateComponent/>
|
||||
{{/if}}
|
|
@ -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}.`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
<div class="user-menu revamped menu-panel drop-down" data-max-width="320">
|
||||
<div class="panel-body">
|
||||
<div class="panel-body-contents">
|
||||
<div
|
||||
id={{concat "quick-access-" this.currentTabId}}
|
||||
class="quick-access-panel"
|
||||
tabindex="-1"
|
||||
aria-labelledby={{concat "user-menu-button-" this.currentTabId}}>
|
||||
<this.currentPanelComponent/>
|
||||
</div>
|
||||
<div class="menu-tabs-container" role="tablist" aria-orientation="vertical" aria-label={{i18n "user_menu.sr_menu_tabs"}}>
|
||||
<div class="top-tabs tabs-list">
|
||||
{{#each this.topTabs as |tab|}}
|
||||
<button
|
||||
class={{concat "btn btn-flat btn-icon no-text" (if (eq tab.id this.currentTabId) " active")}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={{concat "user-menu-button-" tab.id}}
|
||||
tabindex={{if (eq tab.id this.currentTabId) "0" "-1"}}
|
||||
aria-selected={{if (eq tab.id this.currentTabId) "true" "false"}}
|
||||
aria-controls={{concat "quick-access-" tab.id}}
|
||||
data-tab-number={{tab.position}}
|
||||
{{on "click" (fn this.changeTab tab)}}
|
||||
{{!-- template-lint-disable require-context-role --}}
|
||||
>
|
||||
{{d-icon tab.icon}}
|
||||
{{#if tab.count}}
|
||||
<span class="badge-notification">{{tab.count}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="bottom-tabs tabs-list">
|
||||
{{#each this.bottomTabs as |tab|}}
|
||||
<a
|
||||
class="btn btn-flat btn-icon no-text"
|
||||
role="tab"
|
||||
tabindex="-1"
|
||||
href={{tab.href}}
|
||||
data-tab-number={{tab.position}}
|
||||
{{!-- template-lint-disable require-context-role --}}
|
||||
>
|
||||
{{d-icon tab.icon}}
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
<li class={{this.className}}>
|
||||
<a
|
||||
href={{this.linkHref}}
|
||||
title={{this.linkTitle}}
|
||||
{{on "click" this.onClick}}
|
||||
>
|
||||
{{d-icon this.icon}}
|
||||
<div>
|
||||
{{#if this.label}}
|
||||
{{#if this.wrapLabel}}
|
||||
<span class={{concat "notification-label " this.labelWrapperClasses}}>
|
||||
{{this.label}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span>{{this.label}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if this.description}}
|
||||
<span
|
||||
class={{concat "notification-description " this.descriptionElementClasses}}
|
||||
data-topic-id={{this.topicId}}
|
||||
>
|
||||
{{this.description}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
|
@ -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("/") });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
<div class="empty-state">
|
||||
<span class="empty-state-title">
|
||||
{{i18n "user.no_notifications_title"}}
|
||||
</span>
|
||||
<div class="empty-state-body">
|
||||
<p>
|
||||
{{html-safe (i18n
|
||||
"user.no_notifications_body"
|
||||
icon=(d-icon "bell")
|
||||
preferencesUrl=(get-url "/my/preferences/notifications")
|
||||
)}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
import GlimmerComponent from "discourse/components/glimmer";
|
||||
|
||||
export default class UserMenuNotificationsListEmptyState extends GlimmerComponent {}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<UserMenu::Menu/>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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`<SiteHeader />`);
|
||||
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`<SiteHeader />`);
|
||||
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`<SiteHeader />`);
|
||||
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`<SiteHeader />`);
|
||||
await click(".header-dropdown-toggle.current-user");
|
||||
assert.ok(exists(".user-menu.revamped"));
|
||||
await click("header.d-header");
|
||||
assert.ok(!exists(".user-menu.revamped"));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`<UserMenu::Menu/>`;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
|
@ -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 <a>!",
|
||||
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`<UserMenu::NotificationItem @item={{this.notification}}/>`;
|
||||
|
||||
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 <a>!",
|
||||
"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 <a>!",
|
||||
"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 <img> exists");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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`<UserMenu::NotificationsList/>`;
|
||||
|
||||
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"));
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -205,6 +205,7 @@ module SvgSprite
|
|||
"unlock-alt",
|
||||
"upload",
|
||||
"user",
|
||||
"user-cog",
|
||||
"user-edit",
|
||||
"user-friends",
|
||||
"user-plus",
|
||||
|
|
Loading…
Reference in New Issue