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:
Kyle Zhao 2019-09-09 08:03:57 -07:00 committed by Robin Ward
parent 27f7bd4273
commit 9b10a78d82
18 changed files with 765 additions and 231 deletions

View File

@ -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(

View File

@ -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
});
}
});

View File

@ -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> ` : "";
}
});

View File

@ -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);
}
});

View File

@ -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" }
);
}
});

View File

@ -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);
}
});

View File

@ -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
);
}
});

View File

@ -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
});
}
});

View File

@ -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`);
}
});

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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"

View File

@ -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",

View 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
}
]
}
]
}
}
};

View File

@ -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,

View File

@ -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", () => {

View File

@ -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;

View File

@ -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);
}
});