diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6
index 99c41d5350..41c71a48e5 100644
--- a/app/assets/javascripts/discourse/components/site-header.js.es6
+++ b/app/assets/javascripts/discourse/components/site-header.js.es6
@@ -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(
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6
new file mode 100644
index 0000000000..ca1642b897
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6
@@ -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
+ });
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6
new file mode 100644
index 0000000000..a869484cc7
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6
@@ -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: `
${this._usernameHtml()}${emojiUnescape(
+ Handlebars.Utils.escapeExpression(content)
+ )}
`
+ })
+ ]);
+ },
+
+ 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 ? `${this.attrs.username} ` : "";
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6
new file mode 100644
index 0000000000..9988e649d7
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6
@@ -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);
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6
new file mode 100644
index 0000000000..515e702ace
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6
@@ -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" }
+ );
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6
new file mode 100644
index 0000000000..e70985fde2
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6
@@ -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);
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6
new file mode 100644
index 0000000000..b74054e243
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6
@@ -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
+ );
+ }
+});
diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6
index a25240db72..96e56fb7ac 100644
--- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6
@@ -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
+ });
}
});
diff --git a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/user-notifications.js.es6
deleted file mode 100644
index fb19fb56a7..0000000000
--- a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6
+++ /dev/null
@@ -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`);
- }
-});
diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss
index b0b84006d3..44f1d6be7d 100644
--- a/app/assets/stylesheets/common/base/menu-panel.scss
+++ b/app/assets/stylesheets/common/base/menu-panel.scss
@@ -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;
}
}
diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb
index 489dabd865..2c133273cf 100644
--- a/app/controllers/user_actions_controller.rb
+++ b/app/controllers/user_actions_controller.rb
@@ -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,
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 2b0eab6e19..bc0dcbb0e0 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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: "{{username}} {{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"
diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb
index 6bdc6d2653..3630efc0c5 100644
--- a/lib/svg_sprite/svg_sprite.rb
+++ b/lib/svg_sprite/svg_sprite.rb
@@ -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",
diff --git a/test/javascripts/fixtures/private_messages_fixtures.js.es6 b/test/javascripts/fixtures/private_messages_fixtures.js.es6
new file mode 100644
index 0000000000..e6e22c502a
--- /dev/null
+++ b/test/javascripts/fixtures/private_messages_fixtures.js.es6
@@ -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
+ }
+ ]
+ }
+ ]
+ }
+ }
+};
diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6
index e68bb101eb..c801b5d355 100644
--- a/test/javascripts/fixtures/user_fixtures.js.es6
+++ b/test/javascripts/fixtures/user_fixtures.js.es6
@@ -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,
diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6
index ea7a4cd71d..9d229a654c 100644
--- a/test/javascripts/helpers/create-pretender.js.es6
+++ b/test/javascripts/helpers/create-pretender.js.es6
@@ -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", () => {
diff --git a/test/javascripts/helpers/create-store.js.es6 b/test/javascripts/helpers/create-store.js.es6
index dfb5a1b4e2..f0c55cd386 100644
--- a/test/javascripts/helpers/create-store.js.es6
+++ b/test/javascripts/helpers/create-store.js.es6
@@ -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;
diff --git a/test/javascripts/widgets/user-menu-test.js.es6 b/test/javascripts/widgets/user-menu-test.js.es6
index 66abb7cc87..35d73a8542 100644
--- a/test/javascripts/widgets/user-menu-test.js.es6
+++ b/test/javascripts/widgets/user-menu-test.js.es6
@@ -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(//),
+ "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(//),
+ "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);
}
});