mirror of
https://github.com/discourse/discourse.git
synced 2025-03-23 08:09:24 +08:00
FEATURE: Quick access panels in user menu (#8073)
* Extract QuickAccessPanel from UserNotifications. * FEATURE: Quick access panels in user menu. This feature adds quick access panels for bookmarks and personal messages. It allows uses to browse recent items directly in the user menu, without being redirected to the full pages. * REFACTOR: Use QuickAccessItem for messages. Reusing `DefaultNotificationItem` feels nice but it actually requires a lot of extra work that is not needed for a quick access item. Also, `DefaultNotificationItem` shows an incorrect tooptip ("unread private message"), and it is not trivial to remove / override that. * Use a plain JS object instead. An Ember object was required when `DefaultNotificationItem` was used. * Prefix instead suffix `_` for private helpers. * Set to null instead of deleting object keys. JavaScript engines can optimize object property access based on the object’s shape. https://mathiasbynens.be/notes/shapes-ics * Change trivial try/catch to one-liners. * Return the promise in case needs to be waited on. * Refactor showAll to a link with href * Store `emptyStatePlaceholderItemText` in state. * Store items in Session singleton instead. We can drop `staleItems` (and `findStaleItems`) altogether. Because `(old) items === staleItems` when switching back to a quick access panel. * Add `limit` parameter to the `user_actions` API. * Explicitly import Session instead.
This commit is contained in:
parent
27f7bd4273
commit
9b10a78d82
@ -362,6 +362,12 @@ export default SiteHeaderComponent;
|
||||
|
||||
export function headerHeight() {
|
||||
const $header = $("header.d-header");
|
||||
|
||||
// Header may not exist in tests (e.g. in the user menu component test).
|
||||
if ($header.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const headerOffset = $header.offset();
|
||||
const headerOffsetTop = headerOffset ? headerOffset.top : 0;
|
||||
return parseInt(
|
||||
|
@ -0,0 +1,51 @@
|
||||
import { h } from "virtual-dom";
|
||||
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
|
||||
import UserAction from "discourse/models/user-action";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { createWidgetFrom } from "discourse/widgets/widget";
|
||||
import { postUrl } from "discourse/lib/utilities";
|
||||
|
||||
const ICON = "bookmark";
|
||||
|
||||
createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", {
|
||||
buildKey: () => "quick-access-bookmarks",
|
||||
|
||||
hasMore() {
|
||||
// Always show the button to the bookmarks page.
|
||||
return true;
|
||||
},
|
||||
|
||||
showAllHref() {
|
||||
return `${this.attrs.path}/activity/bookmarks`;
|
||||
},
|
||||
|
||||
emptyStatePlaceholderItem() {
|
||||
return h("li.read", this.state.emptyStatePlaceholderItemText);
|
||||
},
|
||||
|
||||
findNewItems() {
|
||||
return ajax("/user_actions.json", {
|
||||
cache: "false",
|
||||
data: {
|
||||
username: this.currentUser.username,
|
||||
filter: UserAction.TYPES.bookmarks,
|
||||
limit: this.estimateItemLimit(),
|
||||
no_results_help_key: "user_activity.no_bookmarks"
|
||||
}
|
||||
}).then(({ user_actions, no_results_help }) => {
|
||||
// The empty state help text for bookmarks page is localized on the
|
||||
// server.
|
||||
this.state.emptyStatePlaceholderItemText = no_results_help;
|
||||
return user_actions;
|
||||
});
|
||||
},
|
||||
|
||||
itemHtml(bookmark) {
|
||||
return this.attach("quick-access-item", {
|
||||
icon: ICON,
|
||||
href: postUrl(bookmark.slug, bookmark.topic_id, bookmark.post_number),
|
||||
content: bookmark.title,
|
||||
username: bookmark.username
|
||||
});
|
||||
}
|
||||
});
|
@ -0,0 +1,43 @@
|
||||
import { h } from "virtual-dom";
|
||||
import RawHtml from "discourse/widgets/raw-html";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
import { iconNode } from "discourse-common/lib/icon-library";
|
||||
|
||||
createWidget("quick-access-item", {
|
||||
tagName: "li",
|
||||
|
||||
buildClasses(attrs) {
|
||||
const result = [];
|
||||
if (attrs.className) {
|
||||
result.push(attrs.className);
|
||||
}
|
||||
if (attrs.read === undefined || attrs.read) {
|
||||
result.push("read");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
html({ icon, href, content }) {
|
||||
return h("a", { attributes: { href } }, [
|
||||
iconNode(icon),
|
||||
new RawHtml({
|
||||
html: `<div>${this._usernameHtml()}${emojiUnescape(
|
||||
Handlebars.Utils.escapeExpression(content)
|
||||
)}</div>`
|
||||
})
|
||||
]);
|
||||
},
|
||||
|
||||
click(e) {
|
||||
this.attrs.read = true;
|
||||
if (this.attrs.action) {
|
||||
e.preventDefault();
|
||||
return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam);
|
||||
}
|
||||
},
|
||||
|
||||
_usernameHtml() {
|
||||
return this.attrs.username ? `<span>${this.attrs.username}</span> ` : "";
|
||||
}
|
||||
});
|
@ -0,0 +1,50 @@
|
||||
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
|
||||
import { createWidgetFrom } from "discourse/widgets/widget";
|
||||
import { postUrl } from "discourse/lib/utilities";
|
||||
|
||||
const ICON = "notification.private_message";
|
||||
|
||||
function toItem(message) {
|
||||
const lastReadPostNumber = message.last_read_post_number || 0;
|
||||
const nextUnreadPostNumber = Math.min(
|
||||
lastReadPostNumber + 1,
|
||||
message.highest_post_number
|
||||
);
|
||||
|
||||
return {
|
||||
content: message.fancy_title,
|
||||
href: postUrl(message.slug, message.id, nextUnreadPostNumber),
|
||||
icon: ICON,
|
||||
read: message.last_read_post_number >= message.highest_post_number,
|
||||
username: message.last_poster_username
|
||||
};
|
||||
}
|
||||
|
||||
createWidgetFrom(QuickAccessPanel, "quick-access-messages", {
|
||||
buildKey: () => "quick-access-messages",
|
||||
emptyStatePlaceholderItemKey: "choose_topic.none_found",
|
||||
|
||||
hasMore() {
|
||||
// Always show the button to the messages page for composing, archiving,
|
||||
// etc.
|
||||
return true;
|
||||
},
|
||||
|
||||
showAllHref() {
|
||||
return `${this.attrs.path}/messages`;
|
||||
},
|
||||
|
||||
findNewItems() {
|
||||
return this.store
|
||||
.findFiltered("topicList", {
|
||||
filter: `topics/private-messages/${this.currentUser.username_lower}`
|
||||
})
|
||||
.then(({ topic_list }) => {
|
||||
return topic_list.topics.map(toItem).slice(0, this.estimateItemLimit());
|
||||
});
|
||||
},
|
||||
|
||||
itemHtml(message) {
|
||||
return this.attach("quick-access-item", message);
|
||||
}
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { createWidgetFrom } from "discourse/widgets/widget";
|
||||
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
|
||||
|
||||
createWidgetFrom(QuickAccessPanel, "quick-access-notifications", {
|
||||
buildKey: () => "quick-access-notifications",
|
||||
emptyStatePlaceholderItemKey: "notifications.empty",
|
||||
|
||||
markReadRequest() {
|
||||
return ajax("/notifications/mark-read", { method: "PUT" });
|
||||
},
|
||||
|
||||
newItemsLoaded() {
|
||||
if (!this.currentUser.enforcedSecondFactor) {
|
||||
this.currentUser.set("unread_notifications", 0);
|
||||
}
|
||||
},
|
||||
|
||||
itemHtml(notification) {
|
||||
const notificationName = this.site.notificationLookup[
|
||||
notification.notification_type
|
||||
];
|
||||
|
||||
return this.attach(
|
||||
`${notificationName.dasherize()}-notification-item`,
|
||||
notification,
|
||||
{},
|
||||
{ fallbackWidgetName: "default-notification-item" }
|
||||
);
|
||||
},
|
||||
|
||||
findNewItems() {
|
||||
return this._findStaleItemsInStore().refresh();
|
||||
},
|
||||
|
||||
showAllHref() {
|
||||
return `${this.attrs.path}/notifications`;
|
||||
},
|
||||
|
||||
hasUnread() {
|
||||
return this.getItems().filterBy("read", false).length > 0;
|
||||
},
|
||||
|
||||
_findStaleItemsInStore() {
|
||||
return this.store.findStale(
|
||||
"notification",
|
||||
{
|
||||
recent: true,
|
||||
silent: this.currentUser.enforcedSecondFactor,
|
||||
limit: this.estimateItemLimit()
|
||||
},
|
||||
{ cacheKey: "recent-notifications" }
|
||||
);
|
||||
}
|
||||
});
|
@ -0,0 +1,143 @@
|
||||
import Session from "discourse/models/session";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { h } from "virtual-dom";
|
||||
import { headerHeight } from "discourse/components/site-header";
|
||||
|
||||
const AVERAGE_ITEM_HEIGHT = 55;
|
||||
|
||||
/**
|
||||
* This tries to enforce a consistent flow of fetching, caching, refreshing,
|
||||
* and rendering for "quick access items".
|
||||
*
|
||||
* There are parts to introducing a new quick access panel:
|
||||
* 1. A user menu link that sends a `quickAccess` action, with a unique `type`.
|
||||
* 2. A `quick-access-${type}` widget, extended from `quick-access-panel`.
|
||||
*/
|
||||
export default createWidget("quick-access-panel", {
|
||||
tagName: "div.quick-access-panel",
|
||||
emptyStatePlaceholderItemKey: "",
|
||||
|
||||
buildKey: () => {
|
||||
throw Error('Cannot attach abstract widget "quick-access-panel".');
|
||||
},
|
||||
|
||||
markReadRequest() {
|
||||
return Ember.RSVP.Promise.resolve();
|
||||
},
|
||||
|
||||
hasUnread() {
|
||||
return false;
|
||||
},
|
||||
|
||||
showAllHref() {
|
||||
return "";
|
||||
},
|
||||
|
||||
hasMore() {
|
||||
return this.getItems().length >= this.estimateItemLimit();
|
||||
},
|
||||
|
||||
findNewItems() {
|
||||
return Ember.RSVP.Promise.resolve([]);
|
||||
},
|
||||
|
||||
newItemsLoaded() {},
|
||||
|
||||
itemHtml(item) {}, // eslint-disable-line no-unused-vars
|
||||
|
||||
emptyStatePlaceholderItem() {
|
||||
if (this.emptyStatePlaceholderItemKey) {
|
||||
return h("li.read", I18n.t(this.emptyStatePlaceholderItemKey));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
defaultState() {
|
||||
return { items: [], loading: false, loaded: false };
|
||||
},
|
||||
|
||||
markRead() {
|
||||
return this.markReadRequest().then(() => {
|
||||
this.refreshNotifications(this.state);
|
||||
});
|
||||
},
|
||||
|
||||
estimateItemLimit() {
|
||||
// Estimate (poorly) the amount of notifications to return.
|
||||
let limit = Math.round(
|
||||
($(window).height() - headerHeight()) / AVERAGE_ITEM_HEIGHT
|
||||
);
|
||||
|
||||
// We REALLY don't want to be asking for negative counts of notifications
|
||||
// less than 5 is also not that useful.
|
||||
if (limit < 5) {
|
||||
limit = 5;
|
||||
} else if (limit > 40) {
|
||||
limit = 40;
|
||||
}
|
||||
|
||||
return limit;
|
||||
},
|
||||
|
||||
refreshNotifications(state) {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getItems().length === 0) {
|
||||
state.loading = true;
|
||||
}
|
||||
|
||||
this.findNewItems()
|
||||
.then(newItems => this.setItems(newItems))
|
||||
.catch(() => this.setItems([]))
|
||||
.finally(() => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
this.newItemsLoaded();
|
||||
this.sendWidgetAction("itemsLoaded", {
|
||||
hasUnread: this.hasUnread(),
|
||||
markRead: () => this.markRead()
|
||||
});
|
||||
this.scheduleRerender();
|
||||
});
|
||||
},
|
||||
|
||||
html(attrs, state) {
|
||||
if (!state.loaded) {
|
||||
this.refreshNotifications(state);
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
return [h("div.spinner-container", h("div.spinner"))];
|
||||
}
|
||||
|
||||
const items = this.getItems().length
|
||||
? this.getItems().map(item => this.itemHtml(item))
|
||||
: [this.emptyStatePlaceholderItem()];
|
||||
|
||||
if (this.hasMore()) {
|
||||
items.push(
|
||||
h(
|
||||
"li.read.last.show-all",
|
||||
this.attach("link", {
|
||||
title: "view_all",
|
||||
icon: "chevron-down",
|
||||
href: this.showAllHref()
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return [h("ul", items)];
|
||||
},
|
||||
|
||||
getItems() {
|
||||
return Session.currentProp(`${this.key}-items`) || [];
|
||||
},
|
||||
|
||||
setItems(newItems) {
|
||||
Session.currentProp(`${this.key}-items`, newItems);
|
||||
}
|
||||
});
|
@ -0,0 +1,91 @@
|
||||
import QuickAccessPanel from "discourse/widgets/quick-access-panel";
|
||||
import { createWidgetFrom } from "discourse/widgets/widget";
|
||||
|
||||
createWidgetFrom(QuickAccessPanel, "quick-access-profile", {
|
||||
buildKey: () => "quick-access-profile",
|
||||
|
||||
hasMore() {
|
||||
// Never show the button to the full profile page.
|
||||
return false;
|
||||
},
|
||||
|
||||
findNewItems() {
|
||||
return Ember.RSVP.Promise.resolve(this._getItems());
|
||||
},
|
||||
|
||||
itemHtml(item) {
|
||||
return this.attach("quick-access-item", item);
|
||||
},
|
||||
|
||||
_getItems() {
|
||||
const items = this._getDefaultItems();
|
||||
if (this._showToggleAnonymousButton()) {
|
||||
items.push(this._toggleAnonymousButton());
|
||||
}
|
||||
if (this.attrs.showLogoutButton) {
|
||||
items.push(this._logOutButton());
|
||||
}
|
||||
return items;
|
||||
},
|
||||
|
||||
_getDefaultItems() {
|
||||
return [
|
||||
{
|
||||
icon: "user",
|
||||
href: `${this.attrs.path}/summary`,
|
||||
content: I18n.t("user.summary.title")
|
||||
},
|
||||
{
|
||||
icon: "stream",
|
||||
href: `${this.attrs.path}/activity`,
|
||||
content: I18n.t("user.activity_stream")
|
||||
},
|
||||
{
|
||||
icon: "envelope",
|
||||
href: `${this.attrs.path}/messages`,
|
||||
content: I18n.t("user.private_messages")
|
||||
},
|
||||
{
|
||||
icon: "cog",
|
||||
href: `${this.attrs.path}/preferences`,
|
||||
content: I18n.t("user.preferences")
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
_toggleAnonymousButton() {
|
||||
if (this.currentUser.is_anonymous) {
|
||||
return {
|
||||
action: "toggleAnonymous",
|
||||
className: "disable-anonymous",
|
||||
content: I18n.t("switch_from_anon"),
|
||||
icon: "ban"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
action: "toggleAnonymous",
|
||||
className: "enable-anonymous",
|
||||
content: I18n.t("switch_to_anon"),
|
||||
icon: "user-secret"
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_logOutButton() {
|
||||
return {
|
||||
action: "logout",
|
||||
className: "logout",
|
||||
content: I18n.t("user.log_out"),
|
||||
icon: "sign-out-alt"
|
||||
};
|
||||
},
|
||||
|
||||
_showToggleAnonymousButton() {
|
||||
return (
|
||||
(this.siteSettings.allow_anonymous_posting &&
|
||||
this.currentUser.trust_level >=
|
||||
this.siteSettings.anonymous_posting_min_trust_level) ||
|
||||
this.currentUser.is_anonymous
|
||||
);
|
||||
}
|
||||
});
|
@ -3,6 +3,17 @@ import { h } from "virtual-dom";
|
||||
import { formatUsername } from "discourse/lib/utilities";
|
||||
import hbs from "discourse/widgets/hbs-compiler";
|
||||
|
||||
const UserMenuAction = {
|
||||
QUICK_ACCESS: "quickAccess"
|
||||
};
|
||||
|
||||
const QuickAccess = {
|
||||
BOOKMARKS: "bookmarks",
|
||||
MESSAGES: "messages",
|
||||
NOTIFICATIONS: "notifications",
|
||||
PROFILE: "profile"
|
||||
};
|
||||
|
||||
let extraGlyphs;
|
||||
|
||||
export function addUserMenuGlyph(glyph) {
|
||||
@ -15,6 +26,8 @@ createWidget("user-menu-links", {
|
||||
|
||||
profileLink() {
|
||||
const link = {
|
||||
action: UserMenuAction.QUICK_ACCESS,
|
||||
actionParam: QuickAccess.PROFILE,
|
||||
route: "user",
|
||||
model: this.currentUser,
|
||||
className: "user-activity-link",
|
||||
@ -30,8 +43,21 @@ createWidget("user-menu-links", {
|
||||
return link;
|
||||
},
|
||||
|
||||
notificationsGlyph() {
|
||||
return {
|
||||
label: "user.notifications",
|
||||
className: "user-notifications-link",
|
||||
icon: "bell",
|
||||
href: `${this.attrs.path}/notifications`,
|
||||
action: UserMenuAction.QUICK_ACCESS,
|
||||
actionParam: QuickAccess.NOTIFICATIONS
|
||||
};
|
||||
},
|
||||
|
||||
bookmarksGlyph() {
|
||||
return {
|
||||
action: UserMenuAction.QUICK_ACCESS,
|
||||
actionParam: QuickAccess.BOOKMARKS,
|
||||
label: "user.bookmarks",
|
||||
className: "user-bookmarks-link",
|
||||
icon: "bookmark",
|
||||
@ -41,6 +67,8 @@ createWidget("user-menu-links", {
|
||||
|
||||
messagesGlyph() {
|
||||
return {
|
||||
action: UserMenuAction.QUICK_ACCESS,
|
||||
actionParam: QuickAccess.MESSAGES,
|
||||
label: "user.private_messages",
|
||||
className: "user-pms-link",
|
||||
icon: "envelope",
|
||||
@ -49,24 +77,20 @@ createWidget("user-menu-links", {
|
||||
},
|
||||
|
||||
linkHtml(link) {
|
||||
if (this.isActive(link)) {
|
||||
link = this.markAsActive(link);
|
||||
}
|
||||
return this.attach("link", link);
|
||||
},
|
||||
|
||||
glyphHtml(glyph) {
|
||||
if (this.isActive(glyph)) {
|
||||
glyph = this.markAsActive(glyph);
|
||||
}
|
||||
return this.attach("link", $.extend(glyph, { hideLabel: true }));
|
||||
},
|
||||
|
||||
html(attrs) {
|
||||
const { currentUser, siteSettings } = this;
|
||||
|
||||
const isAnon = currentUser.is_anonymous;
|
||||
const allowAnon =
|
||||
(siteSettings.allow_anonymous_posting &&
|
||||
currentUser.trust_level >=
|
||||
siteSettings.anonymous_posting_min_trust_level) ||
|
||||
isAnon;
|
||||
|
||||
const path = attrs.path;
|
||||
html() {
|
||||
const links = [this.profileLink()];
|
||||
const glyphs = [];
|
||||
|
||||
@ -81,42 +105,39 @@ createWidget("user-menu-links", {
|
||||
});
|
||||
}
|
||||
|
||||
glyphs.push(this.notificationsGlyph());
|
||||
glyphs.push(this.bookmarksGlyph());
|
||||
|
||||
if (siteSettings.enable_personal_messages) {
|
||||
if (this.siteSettings.enable_personal_messages) {
|
||||
glyphs.push(this.messagesGlyph());
|
||||
}
|
||||
|
||||
if (allowAnon) {
|
||||
if (!isAnon) {
|
||||
glyphs.push({
|
||||
action: "toggleAnonymous",
|
||||
label: "switch_to_anon",
|
||||
className: "enable-anonymous",
|
||||
icon: "user-secret"
|
||||
});
|
||||
} else {
|
||||
glyphs.push({
|
||||
action: "toggleAnonymous",
|
||||
label: "switch_from_anon",
|
||||
className: "disable-anonymous",
|
||||
icon: "ban"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// preferences always goes last
|
||||
glyphs.push({
|
||||
label: "user.preferences",
|
||||
className: "user-preferences-link",
|
||||
icon: "cog",
|
||||
href: `${path}/preferences`
|
||||
});
|
||||
|
||||
return h("ul.menu-links-row", [
|
||||
links.map(l => h("li.user", this.linkHtml(l))),
|
||||
h("li.glyphs", glyphs.map(l => this.glyphHtml(l)))
|
||||
]);
|
||||
},
|
||||
|
||||
markAsActive(definition) {
|
||||
// Clicking on an active quick access tab icon should redirect the user to
|
||||
// the full page.
|
||||
definition.action = null;
|
||||
definition.actionParam = null;
|
||||
|
||||
if (definition.className) {
|
||||
definition.className += " active";
|
||||
} else {
|
||||
definition.className = "active";
|
||||
}
|
||||
|
||||
return definition;
|
||||
},
|
||||
|
||||
isActive({ action, actionParam }) {
|
||||
return (
|
||||
action === UserMenuAction.QUICK_ACCESS &&
|
||||
actionParam === this.attrs.currentQuickAccess
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -148,6 +169,7 @@ export default createWidget("user-menu", {
|
||||
|
||||
defaultState() {
|
||||
return {
|
||||
currentQuickAccess: QuickAccess.NOTIFICATIONS,
|
||||
hasUnread: false,
|
||||
markUnread: null
|
||||
};
|
||||
@ -155,37 +177,18 @@ export default createWidget("user-menu", {
|
||||
|
||||
panelContents() {
|
||||
const path = this.currentUser.get("path");
|
||||
const { currentQuickAccess } = this.state;
|
||||
|
||||
let result = [
|
||||
this.attach("user-menu-links", { path }),
|
||||
this.attach("user-notifications", { path })
|
||||
const result = [
|
||||
this.attach("user-menu-links", {
|
||||
path,
|
||||
currentQuickAccess
|
||||
}),
|
||||
this.quickAccessPanel(path)
|
||||
];
|
||||
|
||||
if (this.settings.showLogoutButton || this.state.hasUnread) {
|
||||
result.push(h("hr.bottom-area"));
|
||||
}
|
||||
|
||||
if (this.settings.showLogoutButton) {
|
||||
result.push(
|
||||
h("div.logout-link", [
|
||||
h(
|
||||
"ul.menu-links",
|
||||
h(
|
||||
"li",
|
||||
this.attach("link", {
|
||||
action: "logout",
|
||||
className: "logout",
|
||||
icon: "sign-out-alt",
|
||||
href: "",
|
||||
label: "user.log_out"
|
||||
})
|
||||
)
|
||||
)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.hasUnread) {
|
||||
result.push(h("hr.bottom-area"));
|
||||
result.push(this.attach("user-menu-dismiss-link"));
|
||||
}
|
||||
|
||||
@ -196,8 +199,8 @@ export default createWidget("user-menu", {
|
||||
return this.state.markRead();
|
||||
},
|
||||
|
||||
notificationsLoaded({ notifications, markRead }) {
|
||||
this.state.hasUnread = notifications.filterBy("read", false).length > 0;
|
||||
itemsLoaded({ hasUnread, markRead }) {
|
||||
this.state.hasUnread = hasUnread;
|
||||
this.state.markRead = markRead;
|
||||
},
|
||||
|
||||
@ -234,5 +237,20 @@ export default createWidget("user-menu", {
|
||||
} else {
|
||||
this.sendWidgetAction("toggleUserMenu");
|
||||
}
|
||||
},
|
||||
|
||||
quickAccess(type) {
|
||||
if (this.state.currentQuickAccess !== type) {
|
||||
this.state.currentQuickAccess = type;
|
||||
}
|
||||
},
|
||||
|
||||
quickAccessPanel(path) {
|
||||
const { showLogoutButton } = this.settings;
|
||||
// This deliberately does NOT fallback to a default quick access panel.
|
||||
return this.attach(`quick-access-${this.state.currentQuickAccess}`, {
|
||||
path,
|
||||
showLogoutButton
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -1,131 +0,0 @@
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { headerHeight } from "discourse/components/site-header";
|
||||
import { h } from "virtual-dom";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default createWidget("user-notifications", {
|
||||
tagName: "div.notifications",
|
||||
buildKey: () => "user-notifications",
|
||||
|
||||
defaultState() {
|
||||
return { notifications: [], loading: false, loaded: false };
|
||||
},
|
||||
|
||||
markRead() {
|
||||
ajax("/notifications/mark-read", { method: "PUT" }).then(() => {
|
||||
this.refreshNotifications(this.state);
|
||||
});
|
||||
},
|
||||
|
||||
refreshNotifications(state) {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// estimate (poorly) the amount of notifications to return
|
||||
let limit = Math.round(($(window).height() - headerHeight()) / 55);
|
||||
// we REALLY don't want to be asking for negative counts of notifications
|
||||
// less than 5 is also not that useful
|
||||
if (limit < 5) {
|
||||
limit = 5;
|
||||
}
|
||||
if (limit > 40) {
|
||||
limit = 40;
|
||||
}
|
||||
|
||||
const silent = this.currentUser.get("enforcedSecondFactor");
|
||||
const stale = this.store.findStale(
|
||||
"notification",
|
||||
{ recent: true, silent, limit },
|
||||
{ cacheKey: "recent-notifications" }
|
||||
);
|
||||
|
||||
if (stale.hasResults) {
|
||||
const results = stale.results;
|
||||
let content = results.get("content");
|
||||
|
||||
// we have to truncate to limit, otherwise we will render too much
|
||||
if (content && content.length > limit) {
|
||||
content = content.splice(0, limit);
|
||||
results.set("content", content);
|
||||
results.set("totalRows", limit);
|
||||
}
|
||||
|
||||
state.notifications = results;
|
||||
} else {
|
||||
state.loading = true;
|
||||
}
|
||||
|
||||
stale
|
||||
.refresh()
|
||||
.then(notifications => {
|
||||
if (!silent) {
|
||||
this.currentUser.set("unread_notifications", 0);
|
||||
}
|
||||
state.notifications = notifications;
|
||||
})
|
||||
.catch(() => {
|
||||
state.notifications = [];
|
||||
})
|
||||
.finally(() => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
this.sendWidgetAction("notificationsLoaded", {
|
||||
notifications: state.notifications,
|
||||
markRead: () => this.markRead()
|
||||
});
|
||||
this.scheduleRerender();
|
||||
});
|
||||
},
|
||||
|
||||
html(attrs, state) {
|
||||
if (!state.loaded) {
|
||||
this.refreshNotifications(state);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
if (state.loading) {
|
||||
result.push(h("div.spinner-container", h("div.spinner")));
|
||||
} else if (state.notifications.length) {
|
||||
const notificationItems = state.notifications.map(notificationAttrs => {
|
||||
const notificationName = this.site.notificationLookup[
|
||||
notificationAttrs.notification_type
|
||||
];
|
||||
|
||||
return this.attach(
|
||||
`${notificationName.dasherize()}-notification-item`,
|
||||
notificationAttrs,
|
||||
{},
|
||||
{ fallbackWidgetName: "default-notification-item" }
|
||||
);
|
||||
});
|
||||
|
||||
result.push(h("hr"));
|
||||
|
||||
const items = [notificationItems];
|
||||
|
||||
if (notificationItems.length > 5) {
|
||||
items.push(
|
||||
h(
|
||||
"li.read.last.heading.show-all",
|
||||
this.attach("button", {
|
||||
title: "notifications.more",
|
||||
icon: "chevron-down",
|
||||
action: "showAllNotifications",
|
||||
className: "btn"
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
result.push(h("ul", items));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
showAllNotifications() {
|
||||
DiscourseURL.routeTo(`${this.attrs.path}/notifications`);
|
||||
}
|
||||
});
|
@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
.notifications {
|
||||
.quick-access-panel {
|
||||
width: 100%;
|
||||
display: table;
|
||||
|
||||
@ -187,6 +187,11 @@
|
||||
padding: 0;
|
||||
> div {
|
||||
overflow: hidden; // clears the text from wrapping below icons
|
||||
|
||||
// Truncate items with more than 2 lines.
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,9 +228,12 @@
|
||||
border-width: 2px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.show-all .btn {
|
||||
.show-all a {
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
color: dark-light-choose($primary-medium, $secondary-high);
|
||||
background: blend-primary-secondary(5%);
|
||||
&:hover {
|
||||
@ -237,29 +245,24 @@
|
||||
@include unselectable;
|
||||
}
|
||||
|
||||
.logout-link,
|
||||
.dismiss-link {
|
||||
display: inline-block;
|
||||
}
|
||||
.dismiss-link {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.notifications .logout {
|
||||
padding: 0.25em;
|
||||
&:hover {
|
||||
background-color: $highlight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
div.menu-links-header {
|
||||
width: 100%;
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.5em;
|
||||
.menu-links-row {
|
||||
border-bottom: 1px solid dark-light-choose($primary-low, $secondary-medium);
|
||||
display: flex;
|
||||
|
||||
// Tabs should have "ears".
|
||||
padding: 0 4px;
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -271,6 +274,42 @@ div.menu-links-header {
|
||||
flex-wrap: wrap;
|
||||
text-align: right;
|
||||
max-width: 65%; //IE11
|
||||
|
||||
a {
|
||||
// Expand the click area a bit.
|
||||
padding-left: 0.6em;
|
||||
padding-right: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
// This is to make sure active and inactive tab icons have the same
|
||||
// size. `box-sizing` does not work and I have no idea why.
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
a.active {
|
||||
border: 1px solid dark-light-choose($primary-low, $secondary-medium);
|
||||
border-bottom: 0;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: z("header") + 1; // Higher than .menu-panel
|
||||
width: 100%;
|
||||
height: 0;
|
||||
content: "";
|
||||
border-top: 1px solid $secondary;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -283,12 +322,24 @@ div.menu-links-header {
|
||||
padding: 0.3em 0.5em;
|
||||
}
|
||||
a.user-activity-link {
|
||||
max-width: 150px;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin: -0.5em 0;
|
||||
max-width: 130px;
|
||||
|
||||
// `overflow: hidden` on `.user-activity-link` would hide the `::after`
|
||||
// pseudo element (used to create the tab-looking effect). Sets `overflow:
|
||||
// hidden` on the child username label instead.
|
||||
overflow: visible;
|
||||
|
||||
span.d-label {
|
||||
display: block;
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include breakpoint(mobile-medium) {
|
||||
max-width: 125px;
|
||||
}
|
||||
@ -311,6 +362,6 @@ div.menu-links-header {
|
||||
}
|
||||
|
||||
.d-icon-user {
|
||||
margin-right: 0.2em;
|
||||
margin-right: 0.475em;
|
||||
}
|
||||
}
|
||||
|
@ -4,19 +4,20 @@ class UserActionsController < ApplicationController
|
||||
|
||||
def index
|
||||
params.require(:username)
|
||||
params.permit(:filter, :offset, :acting_username)
|
||||
params.permit(:filter, :offset, :acting_username, :limit)
|
||||
|
||||
user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
|
||||
raise Discourse::NotFound unless guardian.can_see_profile?(user)
|
||||
|
||||
offset = [0, params[:offset].to_i].max
|
||||
action_types = (params[:filter] || "").split(",").map(&:to_i)
|
||||
limit = params.fetch(:limit, 30).to_i
|
||||
|
||||
opts = {
|
||||
user_id: user.id,
|
||||
user: user,
|
||||
offset: offset,
|
||||
limit: 30,
|
||||
limit: limit,
|
||||
action_types: action_types,
|
||||
guardian: guardian,
|
||||
ignore_private_messages: params[:filter] ? false : true,
|
||||
|
@ -1726,7 +1726,6 @@ en:
|
||||
title: "notifications of @name mentions, replies to your posts and topics, messages, etc"
|
||||
none: "Unable to load notifications at this time."
|
||||
empty: "No notifications found."
|
||||
more: "view older notifications"
|
||||
post_approved: "Your post was approved"
|
||||
reviewable_items: "items requiring review"
|
||||
mentioned: "<span>{{username}}</span> {{description}}"
|
||||
@ -1892,6 +1891,7 @@ en:
|
||||
go_back: "go back"
|
||||
not_logged_in_user: "user page with summary of current activity and preferences"
|
||||
current_user: "go to your user page"
|
||||
view_all: "view all"
|
||||
|
||||
topics:
|
||||
new_messages_marker: "last visit"
|
||||
|
@ -26,6 +26,7 @@ module SvgSprite
|
||||
"ban",
|
||||
"bars",
|
||||
"bed",
|
||||
"bell",
|
||||
"bell-slash",
|
||||
"bold",
|
||||
"book",
|
||||
@ -167,6 +168,7 @@ module SvgSprite
|
||||
"signal",
|
||||
"step-backward",
|
||||
"step-forward",
|
||||
"stream",
|
||||
"sync",
|
||||
"table",
|
||||
"tag",
|
||||
|
79
test/javascripts/fixtures/private_messages_fixtures.js.es6
Normal file
79
test/javascripts/fixtures/private_messages_fixtures.js.es6
Normal file
@ -0,0 +1,79 @@
|
||||
/*jshint maxlen:10000000 */
|
||||
export default {
|
||||
"/topics/private-messages/eviltrout.json": {
|
||||
users: [
|
||||
{
|
||||
id: 19,
|
||||
username: "eviltrout",
|
||||
name: null,
|
||||
avatar_template: "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png"
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
username: "mixtape",
|
||||
name: null,
|
||||
avatar_template: "/letter_avatar_proxy/v4/letter/m/34f0e0/{size}.png"
|
||||
}
|
||||
],
|
||||
primary_groups: [],
|
||||
topic_list: {
|
||||
can_create_topic: true,
|
||||
draft: null,
|
||||
draft_key: "new_topic",
|
||||
draft_sequence: 33,
|
||||
per_page: 30,
|
||||
topics: [
|
||||
{
|
||||
id: 174,
|
||||
title: "BUG: Can not render emoji properly :/",
|
||||
fancy_title: "BUG: Can not render emoji properly :confused:",
|
||||
slug: "bug-can-not-render-emoji-properly",
|
||||
posts_count: 1,
|
||||
reply_count: 0,
|
||||
highest_post_number: 2,
|
||||
image_url: null,
|
||||
created_at: "2019-07-26T01:29:24.008Z",
|
||||
last_posted_at: "2019-07-26T01:29:24.177Z",
|
||||
bumped: true,
|
||||
bumped_at: "2019-07-26T01:29:24.177Z",
|
||||
unseen: false,
|
||||
last_read_post_number: 2,
|
||||
unread: 0,
|
||||
new_posts: 0,
|
||||
pinned: false,
|
||||
unpinned: null,
|
||||
visible: true,
|
||||
closed: false,
|
||||
archived: false,
|
||||
notification_level: 3,
|
||||
bookmarked: false,
|
||||
liked: false,
|
||||
views: 5,
|
||||
like_count: 0,
|
||||
has_summary: false,
|
||||
archetype: "private_message",
|
||||
last_poster_username: "mixtape",
|
||||
category_id: null,
|
||||
pinned_globally: false,
|
||||
featured_link: null,
|
||||
posters: [
|
||||
{
|
||||
extras: "latest single",
|
||||
description: "Original Poster, Most Recent Poster",
|
||||
user_id: 13,
|
||||
primary_group_id: null
|
||||
}
|
||||
],
|
||||
participants: [
|
||||
{
|
||||
extras: "latest",
|
||||
description: null,
|
||||
user_id: 13,
|
||||
primary_group_id: null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
@ -302,7 +302,7 @@ export default {
|
||||
acting_username: "Abhishek_Gupta",
|
||||
acting_name: "Abhishek Gupta",
|
||||
acting_user_id: 8021,
|
||||
title: "How to check the user level via ajax?",
|
||||
title: "How to check the user level via ajax? :/",
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
moderator_action: false,
|
||||
|
@ -139,7 +139,7 @@ export default function() {
|
||||
});
|
||||
|
||||
this.get("/topics/private-messages/eviltrout.json", () => {
|
||||
return response({ topic_list: { topics: [] } });
|
||||
return response(fixturesByUrl["/topics/private-messages/eviltrout.json"]);
|
||||
});
|
||||
|
||||
this.get("/topics/feature_stats.json", () => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Store from "discourse/models/store";
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import TopicListAdapter from "discourse/adapters/topic-list";
|
||||
import TopicTrackingState from "discourse/models/topic-tracking-state";
|
||||
import { buildResolver } from "discourse-common/resolver";
|
||||
|
||||
@ -16,6 +17,11 @@ export default function() {
|
||||
}
|
||||
return this._restAdapter;
|
||||
}
|
||||
if (type === "adapter:topicList") {
|
||||
this._topicListAdapter =
|
||||
this._topicListAdapter || TopicListAdapter.create({ owner: this });
|
||||
return this._topicListAdapter;
|
||||
}
|
||||
if (type === "key-value-store:main") {
|
||||
this._kvs = this._kvs || new KeyValueStore();
|
||||
return this._kvs;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { moduleForWidget, widgetTest } from "helpers/widget-test";
|
||||
|
||||
moduleForWidget("user-menu");
|
||||
@ -8,9 +9,9 @@ widgetTest("basics", {
|
||||
test(assert) {
|
||||
assert.ok(find(".user-menu").length);
|
||||
assert.ok(find(".user-activity-link").length);
|
||||
assert.ok(find(".user-notifications-link").length);
|
||||
assert.ok(find(".user-bookmarks-link").length);
|
||||
assert.ok(find(".user-preferences-link").length);
|
||||
assert.ok(find(".notifications").length);
|
||||
assert.ok(find(".quick-access-panel").length);
|
||||
assert.ok(find(".dismiss-link").length);
|
||||
}
|
||||
});
|
||||
@ -18,8 +19,8 @@ widgetTest("basics", {
|
||||
widgetTest("notifications", {
|
||||
template: '{{mount-widget widget="user-menu"}}',
|
||||
|
||||
test(assert) {
|
||||
const $links = find(".notifications li a");
|
||||
async test(assert) {
|
||||
const $links = find(".quick-access-panel li a");
|
||||
|
||||
assert.equal($links.length, 5);
|
||||
assert.ok($links[0].href.includes("/t/a-slug/123"));
|
||||
@ -62,6 +63,13 @@ widgetTest("notifications", {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const routeToStub = sandbox.stub(DiscourseURL, "routeTo");
|
||||
await click(".user-notifications-link");
|
||||
assert.ok(
|
||||
routeToStub.calledWith(find(".user-notifications-link")[0].href),
|
||||
"a second click should redirect to the full notifications page"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -73,6 +81,7 @@ widgetTest("log out", {
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
await click(".user-activity-link");
|
||||
assert.ok(find(".logout").length);
|
||||
|
||||
await click(".logout");
|
||||
@ -97,8 +106,63 @@ widgetTest("private messages - enabled", {
|
||||
this.siteSettings.enable_personal_messages = true;
|
||||
},
|
||||
|
||||
test(assert) {
|
||||
assert.ok(find(".user-pms-link").length);
|
||||
async test(assert) {
|
||||
const userPmsLink = find(".user-pms-link")[0];
|
||||
assert.ok(userPmsLink);
|
||||
await click(".user-pms-link");
|
||||
|
||||
const message = find(".quick-access-panel li a")[0];
|
||||
assert.ok(message);
|
||||
|
||||
assert.ok(
|
||||
message.href.includes("/t/bug-can-not-render-emoji-properly/174/2"),
|
||||
"should link to the next unread post"
|
||||
);
|
||||
assert.ok(
|
||||
message.innerHTML.includes("mixtape"),
|
||||
"should include the last poster's username"
|
||||
);
|
||||
assert.ok(
|
||||
message.innerHTML.match(/<img.*class="emoji".*>/),
|
||||
"should correctly render emoji in message title"
|
||||
);
|
||||
|
||||
const routeToStub = sandbox.stub(DiscourseURL, "routeTo");
|
||||
await click(".user-pms-link");
|
||||
assert.ok(
|
||||
routeToStub.calledWith(userPmsLink.href),
|
||||
"a second click should redirect to the full private messages page"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
widgetTest("bookmarks", {
|
||||
template: '{{mount-widget widget="user-menu"}}',
|
||||
|
||||
async test(assert) {
|
||||
await click(".user-bookmarks-link");
|
||||
|
||||
const bookmark = find(".quick-access-panel li a")[0];
|
||||
assert.ok(bookmark);
|
||||
|
||||
assert.ok(
|
||||
bookmark.href.includes("/t/how-to-check-the-user-level-via-ajax/11993")
|
||||
);
|
||||
assert.ok(
|
||||
bookmark.innerHTML.includes("Abhishek_Gupta"),
|
||||
"should include the last poster's username"
|
||||
);
|
||||
assert.ok(
|
||||
bookmark.innerHTML.match(/<img.*class="emoji".*>/),
|
||||
"should correctly render emoji in bookmark title"
|
||||
);
|
||||
|
||||
const routeToStub = sandbox.stub(DiscourseURL, "routeTo");
|
||||
await click(".user-bookmarks-link");
|
||||
assert.ok(
|
||||
routeToStub.calledWith(find(".user-bookmarks-link")[0].href),
|
||||
"a second click should redirect to the full bookmarks page"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -115,7 +179,9 @@ widgetTest("anonymous", {
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
await click(".user-activity-link");
|
||||
assert.ok(find(".enable-anonymous").length);
|
||||
|
||||
await click(".enable-anonymous");
|
||||
assert.ok(this.anonymous);
|
||||
}
|
||||
@ -128,7 +194,8 @@ widgetTest("anonymous - disabled", {
|
||||
this.siteSettings.allow_anonymous_posting = false;
|
||||
},
|
||||
|
||||
test(assert) {
|
||||
async test(assert) {
|
||||
await click(".user-activity-link");
|
||||
assert.ok(!find(".enable-anonymous").length);
|
||||
}
|
||||
});
|
||||
@ -141,12 +208,14 @@ widgetTest("anonymous - switch back", {
|
||||
this.currentUser.setProperties({ is_anonymous: true });
|
||||
this.siteSettings.allow_anonymous_posting = true;
|
||||
|
||||
this.on("toggleAnonymous", () => (this.anonymous = true));
|
||||
this.on("toggleAnonymous", () => (this.anonymous = false));
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
await click(".user-activity-link");
|
||||
assert.ok(find(".disable-anonymous").length);
|
||||
|
||||
await click(".disable-anonymous");
|
||||
assert.ok(this.anonymous);
|
||||
assert.notOk(this.anonymous);
|
||||
}
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user