FEATURE: Quick access panels in user menu (#8073)

FEATURE: Quick access panels in user menu (#8073)

  • Extract QuickAccessPanel from UserNotifications.

  • FEATURE: Quick access panels in user menu.

This feature adds quick access panels for bookmarks and personal messages. It allows uses to browse recent items directly in the user menu, without being redirected to the full pages.

  • REFACTOR: Use QuickAccessItem for messages.

Reusing DefaultNotificationItem feels nice but it actually requires a lot of extra work that is not needed for a quick access item.

Also, DefaultNotificationItem shows an incorrect tooptip (“unread private message”), and it is not trivial to remove / override that.

  • Use a plain JS object instead.

An Ember object was required when DefaultNotificationItem was used.

  • Prefix instead suffix _ for private helpers.

  • Set to null instead of deleting object keys.

JavaScript engines can optimize object property access based on the object’s shape. JavaScript engine fundamentals: Shapes and Inline Caches · Mathias Bynens

  • 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.

diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6
index 99c41d5..41c71a4 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 0000000..ca1642b
--- /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 0000000..a869484
--- /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: `<div>${this._usernameHtml()}${emojiUnescape(
+          Handlebars.Utils.escapeExpression(content)
+        )}</div>`
+      })
+    ]);
+  },
+
+  click(e) {
+    this.attrs.read = true;
+    if (this.attrs.action) {
+      e.preventDefault();
+      return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam);
+    }
+  },
+
+  _usernameHtml() {
+    return this.attrs.username ? `<span>${this.attrs.username}</span> ` : "";
+  }
+});
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 0000000..9988e64
--- /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 0000000..515e702
--- /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 0000000..e70985f
--- /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";

[... diff too long, it was truncated ...]

GitHub sha: 9b10a78d

1 Like