From 9b10a78d8256146bea15ba87df7d596b03f7a8b5 Mon Sep 17 00:00:00 2001 From: Kyle Zhao Date: Mon, 9 Sep 2019 08:03:57 -0700 Subject: [PATCH] FEATURE: Quick access panels in user menu (#8073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../discourse/components/site-header.js.es6 | 6 + .../widgets/quick-access-bookmarks.js.es6 | 51 ++++++ .../widgets/quick-access-item.js.es6 | 43 +++++ .../widgets/quick-access-messages.js.es6 | 50 ++++++ .../widgets/quick-access-notifications.js.es6 | 55 +++++++ .../widgets/quick-access-panel.js.es6 | 143 ++++++++++++++++ .../widgets/quick-access-profile.js.es6 | 91 +++++++++++ .../discourse/widgets/user-menu.js.es6 | 152 ++++++++++-------- .../widgets/user-notifications.js.es6 | 131 --------------- .../stylesheets/common/base/menu-panel.scss | 89 +++++++--- app/controllers/user_actions_controller.rb | 5 +- config/locales/client.en.yml | 2 +- lib/svg_sprite/svg_sprite.rb | 2 + .../fixtures/private_messages_fixtures.js.es6 | 79 +++++++++ .../javascripts/fixtures/user_fixtures.js.es6 | 2 +- .../helpers/create-pretender.js.es6 | 2 +- test/javascripts/helpers/create-store.js.es6 | 6 + .../javascripts/widgets/user-menu-test.js.es6 | 87 ++++++++-- 18 files changed, 765 insertions(+), 231 deletions(-) create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 delete mode 100644 app/assets/javascripts/discourse/widgets/user-notifications.js.es6 create mode 100644 test/javascripts/fixtures/private_messages_fixtures.js.es6 diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 99c41d5350c..41c71a48e52 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 00000000000..ca1642b8975 --- /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 00000000000..a869484cc70 --- /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 00000000000..9988e649d7a --- /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 00000000000..515e702ace9 --- /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 00000000000..e70985fde2d --- /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 00000000000..b74054e243a --- /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 a25240db72d..96e56fb7acd 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 fb19fb56a7c..00000000000 --- 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 b0b84006d3f..44f1d6be7d7 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 489dabd8656..2c133273cf3 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 2b0eab6e196..bc0dcbb0e07 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 6bdc6d26530..3630efc0c5b 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 00000000000..e6e22c502ae --- /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 e68bb101ebe..c801b5d3556 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 ea7a4cd71d7..9d229a654c1 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 dfb5a1b4e21..f0c55cd3860 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 66abb7cc877..35d73a85423 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); } });