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