{{! empty div allows for animation }}
{{#if (and this.sidebarEnabled this.showSidebar)}}
@@ -74,7 +87,6 @@
From 21f23cc0328640fd87e30fb3a4b4d62960a8aa43 Mon Sep 17 00:00:00 2001
From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com>
Date: Fri, 23 Feb 2024 11:08:15 -0700
Subject: [PATCH] DEV: Convert header to glimmer (#25214)
Here is a breakdown of the changes that will be implemented in this PR.
# Widgets -> Glimmer
Obviously, the intention of the todo here is to convert the header from widgets to glimmer. This PR splits the respective widgets as so:
### widgets/site-header.js
```mermaid height=200
flowchart TB
A[widgets/site-header.js]
A-->B[components/glimmer-site-header.gjs]
```
### widgets/header.js and children
```mermaid height=200
flowchart TB
A[widgets/header.js]
A-->B[components/glimmer-header.gjs]
B-->C[glimmer-header/contents.gjs]
C-->D[./auth-buttons.gjs]
C-->E[./icons.gjs]
C-->F[./user-menu-wrapper.gjs]
C-->G[./hamburger-dropdown-wrapper.gjs]
C-->H[./user-menu-wrapper.gjs]
C-->I[./sidebar-toggle.gjs]
C-->J[./topic/info.gjs]
```
There are additional components rendered within the `glimmer-header/*` components, but I will leave those out for now. From this view you can see that we split apart the logic of `widgets/header.js` into 10+ components. Breaking apart these mega files has many benefits (readability, etc).
# Services
I have introduced a [header](https://github.com/discourse/discourse/blob/cdb42caa04ebcd1cf395dbe0cea96a13ce007099/app/assets/javascripts/discourse/app/services/header.js) service. This simplifies how we pass around data in the header, as well as fixes a bug we have with "swiping" menu panels.
# Modifiers
Added a [close-on-click-outside](https://github.com/discourse/discourse/blob/cdb42caa04ebcd1cf395dbe0cea96a13ce007099/app/assets/javascripts/discourse/app/modifiers/close-on-click-outside.js) modifier that is built upon the [close-on-click-outside modifier](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/float-kit/addon/modifiers/close-on-click-outside.js) that @jjaffeux built for float-kit. I think we could replace float-kit's implementation with mine and have it in a centralized location as they are extremely similar.
# Tests
Rewrote the existing header tests ([1](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/tests/integration/components/widgets/header-test.js), [2](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js)) as system tests.
# Other
- Converted `widgets/user-status-bubble.js` to a gjs component
- Converted `widgets/sidebar-toggle.js` to a gjs component
- Converted `topicFeaturedLinkNode()` to a gjs component
- Deprecated the [docking mixin](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/mixins/docking.js)
---
.../app/components/discourse-topic.js | 4 +
.../app/components/glimmer-header.gjs | 225 ++++++++
.../glimmer-header/auth-buttons.gjs | 28 +
.../components/glimmer-header/contents.gjs | 61 +++
.../components/glimmer-header/dropdown.gjs | 57 ++
.../hamburger-dropdown-wrapper.gjs | 76 +++
.../app/components/glimmer-header/icons.gjs | 73 +++
.../glimmer-header/panel-portal.gjs | 9 +
.../glimmer-header/search-menu-wrapper.gjs | 9 +
.../glimmer-header/sidebar-toggle.gjs | 37 ++
.../glimmer-header/topic/featured-link.gjs | 26 +
.../components/glimmer-header/topic/info.gjs | 182 +++++++
.../glimmer-header/topic/participant.gjs | 50 ++
.../glimmer-header/topic/status.gjs | 62 +++
.../glimmer-header/user-dropdown.gjs | 53 ++
.../user-dropdown/notifications.gjs | 122 +++++
.../user-dropdown/user-status-bubble.gjs | 25 +
.../glimmer-header/user-menu-wrapper.gjs | 60 +++
.../app/components/glimmer-site-header.gjs | 502 ++++++++++++++++++
.../components/legacy-header-icon-shim.gjs | 25 +
.../discourse/app/components/menu-panel.hbs | 2 +-
.../app/components/search-menu-panel.hbs | 5 +-
.../discourse/app/components/search-menu.hbs | 2 +-
.../app/components/user-menu/items-list.hbs | 1 +
.../app/helpers/bound-avatar-template.js | 4 +-
.../discourse/app/lib/plugin-api.js | 46 +-
.../app/lib/render-topic-featured-link.js | 1 +
.../discourse/app/mixins/docking.js | 1 +
.../app/modifiers/close-on-click-outside.js | 44 ++
.../discourse/app/services/header.js | 10 +
.../discourse/app/templates/application.hbs | 40 +-
.../discourse/app/widgets/header.js | 21 +-
.../discourse/app/widgets/sidebar-toggle.js | 1 +
.../app/widgets/user-status-bubble.js | 2 +
.../discourse/tests/helpers/qunit-helpers.js | 2 +
.../components/site-header-test.js | 2 +
.../components/widgets/header-test.js | 2 +
.../stylesheets/common/base/header.scss | 10 +
app/assets/stylesheets/mobile/header.scss | 2 +-
app/models/user.rb | 4 +
app/serializers/current_user_serializer.rb | 3 +-
config/locales/server.en.yml | 1 +
config/site_settings.yml | 6 +
docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 4 +
spec/system/header_spec.rb | 211 ++++++++
45 files changed, 2087 insertions(+), 26 deletions(-)
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/auth-buttons.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/contents.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/dropdown.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/hamburger-dropdown-wrapper.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/panel-portal.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/search-menu-wrapper.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/sidebar-toggle.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/topic/featured-link.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/topic/info.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/topic/participant.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/topic/status.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown/notifications.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown/user-status-bubble.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-header/user-menu-wrapper.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/glimmer-site-header.gjs
create mode 100644 app/assets/javascripts/discourse/app/components/legacy-header-icon-shim.gjs
create mode 100644 app/assets/javascripts/discourse/app/modifiers/close-on-click-outside.js
create mode 100644 app/assets/javascripts/discourse/app/services/header.js
create mode 100644 spec/system/header_spec.rb
diff --git a/app/assets/javascripts/discourse/app/components/discourse-topic.js b/app/assets/javascripts/discourse/app/components/discourse-topic.js
index 3e9906351b3..8ef20067199 100644
--- a/app/assets/javascripts/discourse/app/components/discourse-topic.js
+++ b/app/assets/javascripts/discourse/app/components/discourse-topic.js
@@ -2,6 +2,7 @@ import { getOwner } from "@ember/application";
import Component from "@ember/component";
import { alias } from "@ember/object/computed";
import { schedule, scheduleOnce, throttle } from "@ember/runloop";
+import { inject as service } from "@ember/service";
import { isBlank } from "@ember/utils";
import $ from "jquery";
import ClickTrack from "discourse/lib/click-track";
@@ -23,6 +24,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
"topic.category.read_restricted:read_restricted",
"topic.deleted:deleted-topic",
],
+ header: service(),
menuVisible: true,
SHORT_POST: 1200,
@@ -54,6 +56,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
_hideTopicInHeader() {
this.appEvents.trigger("header:hide-topic");
+ this.header.topic = null;
this._lastShowTopic = false;
},
@@ -62,6 +65,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
return;
}
this.appEvents.trigger("header:show-topic", topic);
+ this.header.topic = topic;
this._lastShowTopic = true;
},
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header.gjs
new file mode 100644
index 00000000000..9573233f960
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header.gjs
@@ -0,0 +1,225 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { inject as service } from "@ember/service";
+import { modifier } from "ember-modifier";
+import concatClass from "discourse/helpers/concat-class";
+import scrollLock from "discourse/lib/scroll-lock";
+import DiscourseURL from "discourse/lib/url";
+import { scrollTop } from "discourse/mixins/scroll-top";
+import and from "truth-helpers/helpers/and";
+import not from "truth-helpers/helpers/not";
+import or from "truth-helpers/helpers/or";
+import AuthButtons from "./glimmer-header/auth-buttons";
+import Contents from "./glimmer-header/contents";
+import HamburgerDropdownWrapper from "./glimmer-header/hamburger-dropdown-wrapper";
+import Icons from "./glimmer-header/icons";
+import SearchMenuWrapper from "./glimmer-header/search-menu-wrapper";
+import UserMenuWrapper from "./glimmer-header/user-menu-wrapper";
+
+const SEARCH_BUTTON_ID = "search-button";
+
+let _customHeaderClasses = [];
+export function addCustomHeaderClass(className) {
+ _customHeaderClasses.push(className);
+}
+
+export default class GlimmerHeader extends Component {
+ @service router;
+ @service search;
+ @service currentUser;
+ @service site;
+ @service appEvents;
+ @service register;
+ @service header;
+
+ @tracked skipSearchContext = this.site.mobileView;
+ @tracked panelElement;
+
+ appEventsListeners = modifier(() => {
+ this.appEvents.on(
+ "header:keyboard-trigger",
+ this,
+ this.headerKeyboardTrigger
+ );
+ return () => {
+ this.appEvents.off(
+ "header:keyboard-trigger",
+ this,
+ this.headerKeyboardTrigger
+ );
+ };
+ });
+
+ get customHeaderClasses() {
+ return _customHeaderClasses.join(" ");
+ }
+
+ @action
+ headerKeyboardTrigger(msg) {
+ switch (msg.type) {
+ case "search":
+ this.toggleSearchMenu();
+ break;
+ case "user":
+ this.toggleUserMenu();
+ break;
+ case "hamburger":
+ this.toggleHamburger();
+ break;
+ case "page-search":
+ if (!this.togglePageSearch()) {
+ msg.event.preventDefault();
+ msg.event.stopPropagation();
+ }
+ break;
+ }
+ }
+
+ @action
+ toggleSearchMenu() {
+ if (this.site.mobileView) {
+ const context = this.search.searchContext;
+ let params = "";
+ if (context) {
+ params = `?context=${context.type}&context_id=${context.id}&skip_context=${this.skipSearchContext}`;
+ }
+
+ if (this.router.currentRouteName === "full-page-search") {
+ scrollTop();
+ document.querySelector(".full-page-search").focus();
+ return false;
+ } else {
+ return DiscourseURL.routeTo("/search" + params);
+ }
+ }
+
+ this.search.visible = !this.search.visible;
+ if (!this.search.visible) {
+ this.search.highlightTerm = "";
+ this.search.inTopicContext = false;
+ document.getElementById(SEARCH_BUTTON_ID)?.focus();
+ }
+ }
+
+ @action
+ togglePageSearch() {
+ this.search.inTopicContext = false;
+
+ let showSearch = this.router.currentRouteName.startsWith("topic.");
+ // If we're viewing a topic, only intercept search if there are cloaked posts
+ if (showSearch) {
+ const controller = this.register.lookup("controller:topic");
+ const total = controller.get("model.postStream.stream.length") || 0;
+ const chunkSize = controller.get("model.chunk_size") || 0;
+ showSearch =
+ total > chunkSize &&
+ document.querySelector(
+ ".topic-post .cooked, .small-action:not(.time-gap)"
+ )?.length < total;
+ }
+
+ if (this.search.visible) {
+ this.toggleSearchMenu();
+ return showSearch;
+ }
+
+ if (showSearch) {
+ this.search.inTopicContext = true;
+ this.toggleSearchMenu();
+ return false;
+ }
+
+ return true;
+ }
+
+ @action
+ toggleUserMenu() {
+ this.header.userVisible = !this.header.userVisible;
+ this.toggleBodyScrolling(this.header.userVisible);
+ this.args.animateMenu();
+ }
+
+ @action
+ toggleHamburger() {
+ if (this.args.sidebarEnabled && !this.site.narrowDesktopView) {
+ this.args.toggleSidebar();
+ this.args.animateMenu();
+ } else {
+ this.header.hamburgerVisible = !this.header.hamburgerVisible;
+ this.toggleBodyScrolling(this.header.hamburgerVisible);
+ this.args.animateMenu();
+ }
+ }
+
+ @action
+ toggleBodyScrolling(bool) {
+ if (!this.site.mobileView) {
+ return;
+ }
+ scrollLock(bool);
+ }
+
+ @action
+ setPanelElement(element) {
+ this.panelElement = element;
+ }
+
+
+
+
+
+
+
+ {{#if
+ (and
+ (or this.site.mobileView this.site.narrowDesktopView)
+ (or this.header.hamburgerVisible this.header.userVisible)
+ )
+ }}
+
+ {{/if}}
+
+ {{#if this.site.desktopView}}
+ {{#if @sidebarEnabled}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/dropdown.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/dropdown.gjs
new file mode 100644
index 00000000000..895099bd6bb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/dropdown.gjs
@@ -0,0 +1,57 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import concatClass from "discourse/helpers/concat-class";
+import { wantsNewWindow } from "discourse/lib/intercept-click";
+import icon from "discourse-common/helpers/d-icon";
+import i18n from "discourse-common/helpers/i18n";
+import and from "truth-helpers/helpers/and";
+import closeOnClickOutside from "../../modifiers/close-on-click-outside";
+
+export default class Dropdown extends Component {
+ @action
+ click(e) {
+ if (wantsNewWindow(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.args.onClick(e);
+
+ // remove the focus of the header dropdown button after clicking
+ e.target.tagName.toLowerCase() === "button"
+ ? e.target.blur()
+ : e.target.closest("button").blur();
+ }
+
+
+
+
+
+ {{#if this.header.topic}}
+
+
+ {{/if}}
+
+
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs
new file mode 100644
index 00000000000..7a9c01563a6
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs
@@ -0,0 +1,73 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import getURL from "discourse-common/lib/get-url";
+import not from "truth-helpers/helpers/not";
+import or from "truth-helpers/helpers/or";
+import MountWidget from "../mount-widget";
+import Dropdown from "./dropdown";
+import PanelPortal from "./panel-portal";
+import UserDropdown from "./user-dropdown";
+
+let _extraHeaderIcons = [];
+export function addToHeaderIcons(icon) {
+ _extraHeaderIcons.push(icon);
+}
+
+export function clearExtraHeaderIcons() {
+ _extraHeaderIcons = [];
+}
+
+export default class Icons extends Component {
+ @service site;
+ @service currentUser;
+ @service header;
+ @service search;
+
+ _isStringType = (icon) => typeof icon === "string";
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/panel-portal.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/panel-portal.gjs
new file mode 100644
index 00000000000..59dd0c4620a
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/panel-portal.gjs
@@ -0,0 +1,9 @@
+import ConditionalInElement from "../conditional-in-element";
+
+const PanelPortal =
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/topic/participant.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/topic/participant.gjs
new file mode 100644
index 00000000000..efec25e8197
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/topic/participant.gjs
@@ -0,0 +1,50 @@
+import Component from "@glimmer/component";
+import { concat, hash } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import avatar from "discourse/helpers/bound-avatar-template";
+import icon from "discourse-common/helpers/d-icon";
+import getURL from "discourse-common/lib/get-url";
+import eq from "truth-helpers/helpers/eq";
+
+export default class Participant extends Component {
+ @service appEvents;
+
+ get url() {
+ return this.args.type === "user"
+ ? this.args.user.path
+ : getURL(`/g/${this.args.username}`);
+ }
+
+ @action
+ click(e) {
+ this.appEvents.trigger(
+ `topic-header:trigger-${this.args.type}-card`,
+ this.args.username,
+ e.target
+ );
+ e.preventDefault();
+ }
+
+
+
+
+ {{#if (eq @type "user")}}
+ {{avatar @user.avatar_template "tiny" (hash title=@username)}}
+ {{else}}
+
+ {{icon "users"}}
+ {{@username}}
+
+ {{/if}}
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/topic/status.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/topic/status.gjs
new file mode 100644
index 00000000000..415982bf76f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/topic/status.gjs
@@ -0,0 +1,62 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import concatClass from "discourse/helpers/concat-class";
+import TopicStatusIcons from "discourse/helpers/topic-status-icons";
+import { escapeExpression } from "discourse/lib/utilities";
+import icon from "discourse-common/helpers/d-icon";
+import I18n from "discourse-i18n";
+
+export default class Status extends Component {
+ @service currentUser;
+
+ get canAct() {
+ return this.currentUser && !this.args.disableActions;
+ }
+
+ get topicStatuses() {
+ let topicStatuses = [];
+ TopicStatusIcons.render(this.args.topic, (name, key) => {
+ const iconArgs = { class: key === "unpinned" ? "unpinned" : null };
+ const statusIcon = { name, iconArgs };
+
+ const attributes = {
+ title: escapeExpression(I18n.t(`topic_statuses.${key}.help`)),
+ };
+ let klass = ["topic-status"];
+ if (key === "unpinned" || key === "pinned") {
+ klass.push("pin-toggle-button", key);
+ klass = klass.join(" ");
+ }
+ topicStatuses.push({ attributes, klass, icon: statusIcon });
+ });
+
+ return topicStatuses;
+ }
+
+ @action
+ togglePinnedForUser(e) {
+ if (!this.canAct) {
+ return;
+ }
+ const parent = e.target.closest(".topic-statuses");
+ if (parent?.querySelector(".pin-toggle-button")?.contains(e.target)) {
+ this.args.topic.togglePinnedForUser();
+ }
+ }
+
+
+
+ {{#each this.topicStatuses as |status|}}
+ {{! template-lint-disable no-invalid-interactive }}
+
+ {{icon status.icon.name class=status.icon.iconArgs.class}}
+
+ {{/each}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown.gjs
new file mode 100644
index 00000000000..c87d17575ed
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown.gjs
@@ -0,0 +1,53 @@
+import Component from "@glimmer/component";
+import { concat } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import concatClass from "discourse/helpers/concat-class";
+import { wantsNewWindow } from "discourse/lib/intercept-click";
+import i18n from "discourse-common/helpers/i18n";
+import or from "truth-helpers/helpers/or";
+import Notifications from "./user-dropdown/notifications";
+
+export default class UserDropdown extends Component {
+ @service currentUser;
+
+ @action
+ click(e) {
+ if (wantsNewWindow(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.args.toggleUserMenu();
+
+ // remove the focus of the header dropdown button after clicking
+ e.target.tagName.toLowerCase() === "button"
+ ? e.target.blur()
+ : e.target.closest("button").blur();
+ }
+
+
+
+
+
+
+
+ {{#if this.showPM}}
+
+
+ {{#if (or @topic.details.loaded @topic.category)}}
+ {{#if
+ (and
+ @topic.category
+ (or
+ (not @topic.category.isUncategorizedCategory)
+ (not this.siteSettings.suppress_uncategorized_badge)
+ )
+ )
+ }}
+
+ {{/if}}
+
+ {{#if (and @topic.fancyTitle @topic.url)}}
+
+ {{#if @topic.category.parentCategory}}
+ {{#if
+ (and
+ @topic.category.parentCategory.parentCategory
+ (not this.site.mobileView)
+ )
+ }}
+ {{categoryLink
+ @topic.category.parentCategory.parentCategory
+ }}
+ {{/if}}
+
+ {{categoryLink
+ @topic.category.parentCategory
+ (hash hideParent="true")
+ }}
+ {{/if}}
+ {{categoryLink @topic.category}}
+
+ {{/if}}
+
+
+ {{htmlSafe this.tags}}
+
+ {{/if}}
+
+ {{#if this.showPM}}
+ {{#each this.participants as |participant|}}
+
+ {{#if this.siteSettings.topic_featured_link_enabled}}
+ {{icon "moon"}}
+ {{else}}
+ {{#if this.currentUser.new_personal_messages_notifications_count}}
+
+ {{icon "envelope"}}
+
+ {{else if this.currentUser.unseen_reviewable_count}}
+
+ {{icon "flag"}}
+
+ {{else if this.currentUser.all_unread_notifications_count}}
+
+ {{this.currentUser.all_unread_notifications_count}}
+
+ {{/if}}
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown/user-status-bubble.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown/user-status-bubble.gjs
new file mode 100644
index 00000000000..46cc93e595d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/user-dropdown/user-status-bubble.gjs
@@ -0,0 +1,25 @@
+import { hash } from "@ember/helper";
+import emoji from "discourse/helpers/emoji";
+import I18n from "discourse-i18n";
+
+const title = (description, endsAt, timezone) => {
+ let content = description;
+ if (endsAt) {
+ const until = moment
+ .tz(endsAt, timezone)
+ .format(I18n.t("dates.long_date_without_year"));
+ content += `\n${I18n.t("until")} ${until}`;
+ }
+ return content;
+};
+
+const UserStatusBubble =
+
+ {{emoji
+ @status.emoji
+ (hash title=(title @status.description @status.ends_at @timezone))
+ }}
+
+;
+
+export default UserStatusBubble;
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/user-menu-wrapper.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/user-menu-wrapper.gjs
new file mode 100644
index 00000000000..0bd90b20202
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-header/user-menu-wrapper.gjs
@@ -0,0 +1,60 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import { prefersReducedMotion } from "discourse/lib/utilities";
+import { isTesting } from "discourse-common/config/environment";
+import discourseLater from "discourse-common/lib/later";
+import closeOnClickOutside from "../../modifiers/close-on-click-outside";
+import UserMenu from "../user-menu/menu";
+
+export default class UserMenuWrapper extends Component {
+ @action
+ clickOutside(e) {
+ if (
+ e.target.classList.contains("header-cloak") &&
+ !prefersReducedMotion()
+ ) {
+ const panel = document.querySelector(".menu-panel");
+ const headerCloak = document.querySelector(".header-cloak");
+ const finishPosition =
+ document.documentElement.classList["direction"] === "rtl"
+ ? "-340px"
+ : "340px";
+ panel
+ .animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
+ duration: 200,
+ fill: "forwards",
+ easing: "ease-in",
+ })
+ .finished.then(() => {
+ if (isTesting()) {
+ this.args.toggleUserMenu();
+ } else {
+ discourseLater(() => this.args.toggleUserMenu());
+ }
+ });
+ headerCloak.animate([{ opacity: 0 }], {
+ duration: 200,
+ fill: "forwards",
+ easing: "ease-in",
+ });
+ } else {
+ this.args.toggleUserMenu();
+ }
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-site-header.gjs b/app/assets/javascripts/discourse/app/components/glimmer-site-header.gjs
new file mode 100644
index 00000000000..4dc239515e1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-site-header.gjs
@@ -0,0 +1,502 @@
+import Component from "@glimmer/component";
+import { DEBUG } from "@glimmer/env";
+import { action } from "@ember/object";
+import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { cancel, schedule } from "@ember/runloop";
+import { inject as service } from "@ember/service";
+import { waitForPromise } from "@ember/test-waiters";
+import ItsATrap from "@discourse/itsatrap";
+import concatClass from "discourse/helpers/concat-class";
+import scrollLock from "discourse/lib/scroll-lock";
+import SwipeEvents from "discourse/lib/swipe-events";
+import { isTesting } from "discourse-common/config/environment";
+import discourseLater from "discourse-common/lib/later";
+import { bind, debounce } from "discourse-common/utils/decorators";
+import GlimmerHeader from "./glimmer-header";
+
+let _menuPanelClassesToForceDropdown = [];
+const PANEL_WIDTH = 340;
+
+export default class GlimmerSiteHeader extends Component {
+ @service appEvents;
+ @service currentUser;
+ @service site;
+ @service header;
+
+ pxClosed;
+ headerElement;
+ docking;
+ _dockedHeader = false;
+ _animate = false;
+ _headerWrap;
+ _swipeMenuOrigin;
+ _swipeEvents;
+ _applicationElement;
+ _resizeObserver;
+ _docAt;
+
+ constructor() {
+ super(...arguments);
+ this.docking = new Docking(this.dockCheck);
+
+ if (this.currentUser?.staff) {
+ document.body.classList.add("staff");
+ }
+
+ schedule("afterRender", () => this.animateMenu());
+ }
+
+ get dropDownHeaderEnabled() {
+ return !this.sidebarEnabled || this.site.narrowDesktopView;
+ }
+
+ get leftMenuClass() {
+ if (document.querySelector("html").classList["direction"] === "rtl") {
+ return "user-menu";
+ } else {
+ return "hamburger-panel";
+ }
+ }
+
+ @bind
+ updateHeaderOffset() {
+ // Safari likes overscolling the page (on both iOS and macOS).
+ // This shows up as a negative value in window.scrollY.
+ // We can use this to offset the headerWrap's top offset to avoid
+ // jitteriness and bad positioning.
+ const windowOverscroll = Math.min(0, window.scrollY);
+
+ // The headerWrap's top offset can also be a negative value on Safari,
+ // because of the changing height of the viewport (due to the URL bar).
+ // For our use case, it's best to ensure this is clamped to 0.
+ const headerWrapTop = Math.max(
+ 0,
+ Math.floor(this._headerWrap.getBoundingClientRect().top)
+ );
+ let offsetTop = headerWrapTop + windowOverscroll;
+
+ if (DEBUG && isTesting()) {
+ offsetTop -= document
+ .getElementById("ember-testing-container")
+ .getBoundingClientRect().top;
+
+ offsetTop -= 1; // For 1px border on testing container
+ }
+
+ const documentStyle = document.documentElement.style;
+ const currentValue =
+ parseInt(documentStyle.getPropertyValue("--header-offset"), 10) || 0;
+ const newValue = this._headerWrap.offsetHeight + offsetTop;
+ if (currentValue !== newValue) {
+ documentStyle.setProperty("--header-offset", `${newValue}px`);
+ }
+ }
+
+ @bind
+ _onScroll() {
+ schedule("afterRender", this.updateHeaderOffset);
+ }
+
+ @action
+ setupHeader() {
+ this.appEvents.on("user-menu:rendered", this, this.animateMenu);
+ if (this.dropDownHeaderEnabled) {
+ this.appEvents.on(
+ "sidebar-hamburger-dropdown:rendered",
+ this,
+ this.animateMenu
+ );
+ }
+
+ this._headerWrap = document.querySelector(".d-header-wrap");
+ if (this._headerWrap) {
+ schedule("afterRender", () => {
+ this.headerElement = this._headerWrap.querySelector("header.d-header");
+ this.updateHeaderOffset();
+ document.documentElement.style.setProperty(
+ "--header-top",
+ `${this.headerElement.offsetTop}px`
+ );
+ });
+
+ window.addEventListener("scroll", this._onScroll, {
+ passive: true,
+ });
+
+ this._itsatrap = new ItsATrap(this.headerElement);
+ const dirs = ["up", "down"];
+ this._itsatrap.bind(dirs, (e) => this._handleArrowKeysNav(e));
+
+ this._resizeObserver = new ResizeObserver((entries) => {
+ for (let entry of entries) {
+ if (entry.contentRect) {
+ const headerTop = this.headerElement?.offsetTop;
+ document.documentElement.style.setProperty(
+ "--header-top",
+ `${headerTop}px`
+ );
+ this.updateHeaderOffset();
+ }
+ }
+ });
+
+ this._resizeObserver.observe(this._headerWrap);
+
+ this._swipeEvents = new SwipeEvents(this._headerWrap);
+ if (this.site.mobileView) {
+ this._swipeEvents.addTouchListeners();
+ this._headerWrap.addEventListener("swipestart", this.onSwipeStart);
+ this._headerWrap.addEventListener("swipeend", this.onSwipeEnd);
+ this._headerWrap.addEventListener("swipecancel", this.onSwipeCancel);
+ this._headerWrap.addEventListener("swipe", this.onSwipe);
+ }
+ }
+ }
+
+ _handleArrowKeysNav(event) {
+ const activeTab = document.querySelector(
+ ".menu-tabs-container .btn.active"
+ );
+ if (activeTab) {
+ let activeTabNumber = Number(
+ document.activeElement.dataset.tabNumber || activeTab.dataset.tabNumber
+ );
+ const maxTabNumber =
+ document.querySelectorAll(".menu-tabs-container .btn").length - 1;
+ const isNext = event.key === "ArrowDown";
+ let nextTab = isNext ? activeTabNumber + 1 : activeTabNumber - 1;
+ if (isNext && nextTab > maxTabNumber) {
+ nextTab = 0;
+ }
+ if (!isNext && nextTab < 0) {
+ nextTab = maxTabNumber;
+ }
+ event.preventDefault();
+ document
+ .querySelector(
+ `.menu-tabs-container .btn[data-tab-number='${nextTab}']`
+ )
+ .focus();
+ }
+ }
+
+ @action
+ animateMenu() {
+ const menuPanels = document.querySelectorAll(".menu-panel");
+
+ if (menuPanels.length === 0) {
+ this._animate = this.site.mobileView || this.site.narrowDesktopView;
+ return;
+ }
+
+ let viewMode =
+ this.site.mobileView || this.site.narrowDesktopView
+ ? "slide-in"
+ : "drop-down";
+
+ menuPanels.forEach((panel) => {
+ if (menuPanelContainsClass(panel)) {
+ viewMode = "drop-down";
+ this._animate = false;
+ }
+
+ const cloakElement = document.querySelector(".header-cloak");
+
+ panel.classList.remove("drop-down");
+ panel.classList.remove("slide-in");
+ panel.classList.add(viewMode);
+
+ if (this._animate) {
+ let animationFinished = null;
+ let finalPosition = PANEL_WIDTH;
+ this._swipeMenuOrigin = "right";
+ if (
+ (this.site.mobileView || this.site.narrowDesktopView) &&
+ panel.parentElement.classList.contains(this.leftMenuClass)
+ ) {
+ this._swipeMenuOrigin = "left";
+ finalPosition = -PANEL_WIDTH;
+ }
+ animationFinished = panel.animate(
+ [{ transform: `translate3d(${finalPosition}px, 0, 0)` }],
+ {
+ fill: "forwards",
+ }
+ ).finished;
+
+ if (isTesting()) {
+ waitForPromise(animationFinished);
+ }
+
+ cloakElement.animate([{ opacity: 0 }], { fill: "forwards" });
+ cloakElement.style.display = "block";
+
+ animationFinished.then(() => {
+ if (isTesting()) {
+ this._animateOpening(panel);
+ } else {
+ discourseLater(() => this._animateOpening(panel));
+ }
+ });
+ }
+
+ this._animate = false;
+ });
+ }
+
+ @bind
+ dockCheck() {
+ if (this._docAt === null) {
+ if (!this.headerElement) {
+ return;
+ }
+ this._docAt = this.headerElement.offsetTop;
+ }
+
+ const main = (this._applicationElement ??=
+ document.querySelector(".ember-application"));
+ const offsetTop = main?.offsetTop ?? 0;
+ const offset = window.pageYOffset - offsetTop;
+ if (offset >= this._docAt) {
+ if (!this._dockedHeader) {
+ document.body.classList.add("docked");
+ this._dockedHeader = true;
+ }
+ } else {
+ if (this._dockedHeader) {
+ document.body.classList.remove("docked");
+ this._dockedHeader = false;
+ }
+ }
+ }
+
+ @bind
+ _animateOpening(panel, event = null) {
+ const cloakElement = document.querySelector(".header-cloak");
+ let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
+ if (event && this.pxClosed > 0) {
+ durationMs = this._swipeEvents.getMaxAnimationTimeMs(
+ this.pxClosed / Math.abs(event.velocityX)
+ );
+ }
+ const timing = {
+ duration: durationMs > 0 ? durationMs : 0,
+ fill: "forwards",
+ easing: "ease-out",
+ };
+ panel.animate([{ transform: `translate3d(0, 0, 0)` }], timing);
+ cloakElement?.animate?.([{ opacity: 1 }], timing);
+ this.pxClosed = null;
+ }
+
+ @bind
+ _animateClosing(event, panel, menuOrigin) {
+ this._animate = true;
+ const cloakElement = document.querySelector(".header-cloak");
+ let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
+ if (event && this.pxClosed > 0) {
+ const distancePx = PANEL_WIDTH - this.pxClosed;
+ durationMs = this._swipeEvents.getMaxAnimationTimeMs(
+ distancePx / Math.abs(event.velocityX)
+ );
+ }
+ const timing = {
+ duration: durationMs > 0 ? durationMs : 0,
+ fill: "forwards",
+ };
+
+ let endPosition = -PANEL_WIDTH; //origin left
+ if (menuOrigin === "right") {
+ endPosition = PANEL_WIDTH;
+ }
+ panel.animate(
+ [{ transform: `translate3d(${endPosition}px, 0, 0)` }],
+ timing
+ );
+ if (cloakElement) {
+ cloakElement.animate([{ opacity: 0 }], timing);
+ cloakElement.style.display = "none";
+
+ // to ensure that the cloak is cleared after animation we need to toggle any active menus
+ if (this.header.hamburgerVisible || this.header.userVisible) {
+ this.header.hamburgerVisible = false;
+ this.header.userVisible = false;
+ }
+ }
+ this.pxClosed = null;
+ }
+
+ @bind
+ onSwipeStart(event) {
+ const e = event.detail;
+ const center = e.center;
+ const swipeOverValidElement = document
+ .elementsFromPoint(center.x, center.y)
+ .some(
+ (ele) =>
+ ele.classList.contains("panel-body") ||
+ ele.classList.contains("header-cloak")
+ );
+ if (
+ swipeOverValidElement &&
+ (e.direction === "left" || e.direction === "right")
+ ) {
+ scrollLock(true, document.querySelector(".panel-body"));
+ } else {
+ event.preventDefault();
+ }
+ }
+
+ @bind
+ onSwipeEnd(event) {
+ const e = event.detail;
+ const menuPanels = document.querySelectorAll(".menu-panel");
+ scrollLock(false, document.querySelector(".panel-body"));
+ menuPanels.forEach((panel) => {
+ if (this._swipeEvents.shouldCloseMenu(e, this._swipeMenuOrigin)) {
+ this._animateClosing(e, panel, this._swipeMenuOrigin);
+ } else {
+ this._animateOpening(panel, e);
+ }
+ });
+ }
+
+ @bind
+ onSwipeCancel() {
+ const menuPanels = document.querySelectorAll(".menu-panel");
+ scrollLock(false, document.querySelector(".panel-body"));
+ menuPanels.forEach((panel) => {
+ this._animateOpening(panel);
+ });
+ }
+
+ @bind
+ onSwipe(event) {
+ const e = event.detail;
+
+ const movingElement = document.querySelector(".menu-panel");
+ const cloakElement = document.querySelector(".header-cloak");
+
+ //origin left
+ this.pxClosed = Math.max(0, -e.deltaX);
+ let translation = -this.pxClosed;
+ if (this._swipeMenuOrigin === "right") {
+ this.pxClosed = Math.max(0, e.deltaX);
+ translation = this.pxClosed;
+ }
+
+ movingElement.animate(
+ [{ transform: `translate3d(${translation}px, 0, 0)` }],
+ {
+ fill: "forwards",
+ }
+ );
+ cloakElement?.animate?.(
+ [
+ {
+ opacity: (PANEL_WIDTH - this.pxClosed) / PANEL_WIDTH,
+ },
+ ],
+ { fill: "forwards" }
+ );
+ }
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.docking.destroy();
+ this.appEvents.off("user-menu:rendered", this, this.animateMenu);
+
+ if (this.dropDownHeaderEnabled) {
+ this.appEvents.off(
+ "sidebar-hamburger-dropdown:rendered",
+ this,
+ this.animateMenu
+ );
+ }
+
+ this._itsatrap?.destroy();
+ this._itsatrap = null;
+
+ window.removeEventListener("scroll", this._onScroll);
+ this._resizeObserver?.disconnect();
+ if (this.site.mobileView) {
+ this._headerWrap.removeEventListener("swipestart", this.onSwipeStart);
+ this._headerWrap.removeEventListener("swipeend", this.onSwipeEnd);
+ this._headerWrap.removeEventListener("swipecancel", this.onSwipeCancel);
+ this._headerWrap.removeEventListener("swipe", this.onSwipe);
+ this._swipeEvents.removeTouchListeners();
+ }
+ }
+
+
+
+
+
+}
+
+const INITIAL_DELAY_MS = 50;
+const DEBOUNCE_MS = 5;
+class Docking {
+ dockCheck = null;
+ _initialTimer = null;
+ _queuedTimer = null;
+
+ constructor(dockCheck) {
+ this.dockCheck = dockCheck;
+ window.addEventListener("scroll", this.queueDockCheck, { passive: true });
+
+ // dockCheck might happen too early on full page refresh
+ this._initialTimer = discourseLater(this, this.dockCheck, INITIAL_DELAY_MS);
+ }
+
+ @debounce(DEBOUNCE_MS)
+ queueDockCheck() {
+ this._queuedTimer = this.dockCheck;
+ }
+
+ @action
+ destroy() {
+ if (this._queuedTimer) {
+ cancel(this._queuedTimer);
+ }
+
+ cancel(this._initialTimer);
+ window.removeEventListener("scroll", this.queueDockCheck);
+ }
+}
+
+function menuPanelContainsClass(menuPanel) {
+ if (!_menuPanelClassesToForceDropdown) {
+ return false;
+ }
+
+ for (let className of _menuPanelClassesToForceDropdown) {
+ if (menuPanel.classList.contains(className)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function forceDropdownForMenuPanels(classNames) {
+ if (typeof classNames === "string") {
+ classNames = [classNames];
+ }
+ return _menuPanelClassesToForceDropdown.push(...classNames);
+}
diff --git a/app/assets/javascripts/discourse/app/components/legacy-header-icon-shim.gjs b/app/assets/javascripts/discourse/app/components/legacy-header-icon-shim.gjs
new file mode 100644
index 00000000000..f41426e00de
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/legacy-header-icon-shim.gjs
@@ -0,0 +1,25 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { schedule } from "@ember/runloop";
+import { PANEL_WRAPPER_ID } from "discourse/widgets/header";
+import PanelPortal from "./glimmer-header/panel-portal";
+
+export default class LegacyHeaderIconShim extends Component {
+ @tracked panelElement;
+
+ constructor() {
+ super(...arguments);
+ schedule("afterRender", () => {
+ this.panelElement = document.querySelector(`#${PANEL_WRAPPER_ID}`);
+ });
+ }
+
+
+ {{#let
+ (component PanelPortal panelElement=this.panelElement)
+ as |panelPortal|
+ }}
+ <@component @panelPortal={{panelPortal}} />
+ {{/let}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/menu-panel.hbs b/app/assets/javascripts/discourse/app/components/menu-panel.hbs
index e45928f73cb..2bd6d99bf21 100644
--- a/app/assets/javascripts/discourse/app/components/menu-panel.hbs
+++ b/app/assets/javascripts/discourse/app/components/menu-panel.hbs
@@ -1,5 +1,5 @@