mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 11:23:25 +08:00
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](cdb42caa04/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](cdb42caa04/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)
This commit is contained in:
parent
d10b1aaedd
commit
21f23cc032
|
@ -2,6 +2,7 @@ import { getOwner } from "@ember/application";
|
||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
import { alias } from "@ember/object/computed";
|
import { alias } from "@ember/object/computed";
|
||||||
import { schedule, scheduleOnce, throttle } from "@ember/runloop";
|
import { schedule, scheduleOnce, throttle } from "@ember/runloop";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
import { isBlank } from "@ember/utils";
|
import { isBlank } from "@ember/utils";
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
import ClickTrack from "discourse/lib/click-track";
|
import ClickTrack from "discourse/lib/click-track";
|
||||||
|
@ -23,6 +24,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
||||||
"topic.category.read_restricted:read_restricted",
|
"topic.category.read_restricted:read_restricted",
|
||||||
"topic.deleted:deleted-topic",
|
"topic.deleted:deleted-topic",
|
||||||
],
|
],
|
||||||
|
header: service(),
|
||||||
menuVisible: true,
|
menuVisible: true,
|
||||||
SHORT_POST: 1200,
|
SHORT_POST: 1200,
|
||||||
|
|
||||||
|
@ -54,6 +56,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
||||||
|
|
||||||
_hideTopicInHeader() {
|
_hideTopicInHeader() {
|
||||||
this.appEvents.trigger("header:hide-topic");
|
this.appEvents.trigger("header:hide-topic");
|
||||||
|
this.header.topic = null;
|
||||||
this._lastShowTopic = false;
|
this._lastShowTopic = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -62,6 +65,7 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.appEvents.trigger("header:show-topic", topic);
|
this.appEvents.trigger("header:show-topic", topic);
|
||||||
|
this.header.topic = topic;
|
||||||
this._lastShowTopic = true;
|
this._lastShowTopic = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class={{concatClass this.customHeaderClasses "d-header"}}
|
||||||
|
{{this.appEventsListeners}}
|
||||||
|
>
|
||||||
|
<div class="wrap">
|
||||||
|
<Contents
|
||||||
|
@sidebarEnabled={{@sidebarEnabled}}
|
||||||
|
@toggleHamburger={{this.toggleHamburger}}
|
||||||
|
@showSidebar={{@showSidebar}}
|
||||||
|
>
|
||||||
|
{{#unless this.currentUser}}
|
||||||
|
<AuthButtons
|
||||||
|
@showCreateAccount={{@showCreateAccount}}
|
||||||
|
@showLogin={{@showLogin}}
|
||||||
|
@canSignUp={{@canSignUp}}
|
||||||
|
/>
|
||||||
|
{{/unless}}
|
||||||
|
{{#if
|
||||||
|
(not (and this.siteSettings.login_required (not this.currentUser)))
|
||||||
|
}}
|
||||||
|
<Icons
|
||||||
|
@sidebarEnabled={{@sidebarEnabled}}
|
||||||
|
@toggleSearchMenu={{this.toggleSearchMenu}}
|
||||||
|
@toggleHamburger={{this.toggleHamburger}}
|
||||||
|
@toggleUserMenu={{this.toggleUserMenu}}
|
||||||
|
@searchButtonId={{SEARCH_BUTTON_ID}}
|
||||||
|
@panelElement={{this.panelElement}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.search.visible}}
|
||||||
|
<SearchMenuWrapper @closeSearchMenu={{this.toggleSearchMenu}} />
|
||||||
|
{{else if this.header.hamburgerVisible}}
|
||||||
|
<HamburgerDropdownWrapper
|
||||||
|
@toggleHamburger={{this.toggleHamburger}}
|
||||||
|
/>
|
||||||
|
{{else if this.header.userVisible}}
|
||||||
|
<UserMenuWrapper @toggleUserMenu={{this.toggleUserMenu}} />
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div id="additional-panel-wrapper" {{didInsert this.setPanelElement}}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if
|
||||||
|
(and
|
||||||
|
(or this.site.mobileView this.site.narrowDesktopView)
|
||||||
|
(or this.header.hamburgerVisible this.header.userVisible)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<div class="header-cloak"></div>
|
||||||
|
{{/if}}
|
||||||
|
</Contents>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import and from "truth-helpers/helpers/and";
|
||||||
|
import not from "truth-helpers/helpers/not";
|
||||||
|
|
||||||
|
export default class AuthButtons extends Component {
|
||||||
|
@service header;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="header-buttons">
|
||||||
|
{{#if (and @canSignUp (not this.header.topic))}}
|
||||||
|
<DButton
|
||||||
|
class="btn-primary btn-small sign-up-button"
|
||||||
|
@action={{@showCreateAccount}}
|
||||||
|
@label="sign_up"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
class="btn-primary btn-small login-button"
|
||||||
|
@action={{@showLogin}}
|
||||||
|
@label="log_in"
|
||||||
|
@icon="user"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import and from "truth-helpers/helpers/and";
|
||||||
|
import BootstrapModeNotice from "../bootstrap-mode-notice";
|
||||||
|
import MountWidget from "../mount-widget";
|
||||||
|
import PluginOutlet from "../plugin-outlet";
|
||||||
|
import SidebarToggle from "./sidebar-toggle";
|
||||||
|
import TopicInfo from "./topic/info";
|
||||||
|
|
||||||
|
export default class Contents extends Component {
|
||||||
|
@service site;
|
||||||
|
@service currentUser;
|
||||||
|
@service siteSettings;
|
||||||
|
@service header;
|
||||||
|
|
||||||
|
get topicPresent() {
|
||||||
|
return !!this.header.topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="contents">
|
||||||
|
{{#if this.site.desktopView}}
|
||||||
|
{{#if @sidebarEnabled}}
|
||||||
|
<SidebarToggle @toggleHamburger={{@toggleHamburger}} />
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="home-logo-wrapper-outlet">
|
||||||
|
<PluginOutlet @name="home-logo-wrapper">
|
||||||
|
<MountWidget
|
||||||
|
@widget="home-logo"
|
||||||
|
@args={{hash minimized=this.topicPresent}}
|
||||||
|
/>
|
||||||
|
</PluginOutlet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.header.topic}}
|
||||||
|
<TopicInfo @topic={{this.header.topic}} />
|
||||||
|
{{else if
|
||||||
|
(and
|
||||||
|
this.siteSettings.bootstrap_mode_enabled
|
||||||
|
this.currentUser.staff
|
||||||
|
this.site.desktopView
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<div class="d-header-mode">
|
||||||
|
<BootstrapModeNotice />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="before-header-panel-outlet">
|
||||||
|
<PluginOutlet
|
||||||
|
@name="before-header-panel"
|
||||||
|
@outletArgs={{hash topic=this.header.topic}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="panel" role="navigation">{{yield}}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class={{concatClass
|
||||||
|
@className
|
||||||
|
(if @active "active")
|
||||||
|
"header-dropdown-toggle"
|
||||||
|
}}
|
||||||
|
{{(if
|
||||||
|
(and @active @targetSelector)
|
||||||
|
(modifier
|
||||||
|
closeOnClickOutside @onClick (hash targetSelector=@targetSelector)
|
||||||
|
)
|
||||||
|
)}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="button icon btn-flat"
|
||||||
|
aria-expanded={{@active}}
|
||||||
|
aria-haspopup="true"
|
||||||
|
href={{@href}}
|
||||||
|
data-auto-route="true"
|
||||||
|
title={{i18n @title}}
|
||||||
|
aria-label={{i18n @title}}
|
||||||
|
id={{@iconId}}
|
||||||
|
{{on "click" this.click}}
|
||||||
|
>
|
||||||
|
{{icon @icon}}
|
||||||
|
{{@contents}}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
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 HamburgerDropdown from "../sidebar/hamburger-dropdown";
|
||||||
|
|
||||||
|
export default class HamburgerDropdownWrapper extends Component {
|
||||||
|
@action
|
||||||
|
click(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (
|
||||||
|
e.target.closest(
|
||||||
|
".sidebar-section-header-button, .sidebar-section-link-button, .sidebar-section-link"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.args.toggleHamburger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.querySelector("html").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.toggleHamburger();
|
||||||
|
} else {
|
||||||
|
discourseLater(() => this.args.toggleHamburger());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
headerCloak.animate([{ opacity: 0 }], {
|
||||||
|
duration: 200,
|
||||||
|
fill: "forwards",
|
||||||
|
easing: "ease-in",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.args.toggleHamburger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="hamburger-dropdown-wrapper"
|
||||||
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
|
{{on "click" this.click}}
|
||||||
|
{{! we don't want to close the hamburger dropdown when clicking on the hamburger dropdown itself
|
||||||
|
so we use the secondaryTargetSelector to prevent that }}
|
||||||
|
{{closeOnClickOutside
|
||||||
|
this.clickOutside
|
||||||
|
(hash
|
||||||
|
targetSelector=".hamburger-panel"
|
||||||
|
secondaryTargetSelector=".hamburger-dropdown"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HamburgerDropdown />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="icons d-header-icons">
|
||||||
|
{{#each _extraHeaderIcons as |Icon|}}
|
||||||
|
{{#if (this._isStringType Icon)}}
|
||||||
|
<MountWidget @widget={{Icon}} />
|
||||||
|
{{else}}
|
||||||
|
{{#let
|
||||||
|
(component PanelPortal panelElement=@panelElement)
|
||||||
|
as |panelPortal|
|
||||||
|
}}
|
||||||
|
<Icon @panelPortal={{panelPortal}} />
|
||||||
|
{{/let}}
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
@title="search.title"
|
||||||
|
@icon="search"
|
||||||
|
@iconId={{@searchButtonId}}
|
||||||
|
@onClick={{@toggleSearchMenu}}
|
||||||
|
@active={{this.search.visible}}
|
||||||
|
@href={{getURL "/search"}}
|
||||||
|
@className="search-dropdown"
|
||||||
|
@targetSelector=".search-menu-panel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{#if (or (not @sidebarEnabled) this.site.mobileView)}}
|
||||||
|
<Dropdown
|
||||||
|
@title="hamburger_menu"
|
||||||
|
@icon="bars"
|
||||||
|
@iconId="toggle-hamburger-menu"
|
||||||
|
@active={{this.header.hamburgerVisible}}
|
||||||
|
@onClick={{@toggleHamburger}}
|
||||||
|
@className="hamburger-dropdown"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.currentUser}}
|
||||||
|
<UserDropdown
|
||||||
|
@active={{this.header.userVisible}}
|
||||||
|
@toggleUserMenu={{@toggleUserMenu}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import ConditionalInElement from "../conditional-in-element";
|
||||||
|
|
||||||
|
const PanelPortal = <template>
|
||||||
|
<ConditionalInElement @element={{@panelElement}}>
|
||||||
|
{{yield}}
|
||||||
|
</ConditionalInElement>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default PanelPortal;
|
|
@ -0,0 +1,9 @@
|
||||||
|
import SearchMenuPanel from "../search-menu-panel";
|
||||||
|
|
||||||
|
const SearchMenuWrapper = <template>
|
||||||
|
<div class="search-menu glimmer-search-menu" aria-live="polite">
|
||||||
|
<SearchMenuPanel @closeSearchMenu={{@closeSearchMenu}} />
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default SearchMenuWrapper;
|
|
@ -0,0 +1,37 @@
|
||||||
|
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 icon from "discourse-common/helpers/d-icon";
|
||||||
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
|
|
||||||
|
export default class SidebarToggle extends Component {
|
||||||
|
@service site;
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleWithBlur(e) {
|
||||||
|
this.args.toggleHamburger();
|
||||||
|
// remove the focus of the header dropdown button after clicking
|
||||||
|
e.target.tagName.toLowerCase() === "button"
|
||||||
|
? e.target.blur()
|
||||||
|
: e.target.closest("button").blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="header-sidebar-toggle">
|
||||||
|
<button
|
||||||
|
title={{i18n "sidebar.title"}}
|
||||||
|
class={{concatClass
|
||||||
|
"btn btn-flat btn-sidebar-toggle no-text btn-icon"
|
||||||
|
(if this.site.narrowDesktopView "narrow-desktop")
|
||||||
|
}}
|
||||||
|
aria-expanded={{if @showSidebar "true" "false"}}
|
||||||
|
aria-controls="d-sidebar"
|
||||||
|
{{on "click" this.toggleWithBlur}}
|
||||||
|
>
|
||||||
|
{{icon "bars"}}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { extractLinkMeta } from "discourse/lib/render-topic-featured-link";
|
||||||
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
|
||||||
|
export default class FeaturedLink extends Component {
|
||||||
|
@service header;
|
||||||
|
|
||||||
|
get meta() {
|
||||||
|
return extractLinkMeta(this.header.topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.meta}}
|
||||||
|
<a
|
||||||
|
class="topic-featured-link"
|
||||||
|
rel={{this.meta.rel}}
|
||||||
|
target={{this.meta.target}}
|
||||||
|
href={{this.meta.href}}
|
||||||
|
>
|
||||||
|
{{icon "external-link-alt"}}
|
||||||
|
{{this.meta.domain}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { fn, hash } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import categoryLink from "discourse/helpers/category-link";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
import renderTags from "discourse/lib/render-tags";
|
||||||
|
import DiscourseURL from "discourse/lib/url";
|
||||||
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
|
import and from "truth-helpers/helpers/and";
|
||||||
|
import gt from "truth-helpers/helpers/gt";
|
||||||
|
import not from "truth-helpers/helpers/not";
|
||||||
|
import or from "truth-helpers/helpers/or";
|
||||||
|
import PluginOutlet from "../../plugin-outlet";
|
||||||
|
import FeaturedLink from "./featured-link";
|
||||||
|
import Participant from "./participant";
|
||||||
|
import Status from "./status";
|
||||||
|
|
||||||
|
export default class Info extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service site;
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
get showPM() {
|
||||||
|
return !this.args.topic.is_warning && this.args.topic.isPrivateMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalParticipants() {
|
||||||
|
return (
|
||||||
|
(this.args.topic.details.allowed_users?.length || 0) +
|
||||||
|
(this.args.topic.allowed_groups?.length || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxExtraItems() {
|
||||||
|
return this.args.topic.tags?.length > 0 ? 5 : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
get twoRows() {
|
||||||
|
return (
|
||||||
|
this.tags?.length ||
|
||||||
|
this.showPM ||
|
||||||
|
this.siteSettings.topic_featured_link_enabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get tags() {
|
||||||
|
if (this.args.topic.tags) {
|
||||||
|
return renderTags(this.args.topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get remainingParticipantCount() {
|
||||||
|
return this.totalParticipants - this.maxExtraItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
get participants() {
|
||||||
|
const participants = [
|
||||||
|
...this.args.topic.details.allowed_users,
|
||||||
|
...this.args.topic.details.allowed_groups,
|
||||||
|
];
|
||||||
|
return participants.slice(0, this.maxExtraItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
jumpToTopPost(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.args.topic) {
|
||||||
|
DiscourseURL.routeTo(this.args.topic.firstPostUrl, {
|
||||||
|
keepFilter: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class={{concatClass (if this.twoRows "two-rows") "extra-info-wrapper"}}
|
||||||
|
>
|
||||||
|
<div class={{concatClass (if this.twoRows "two-rows") "extra-info"}}>
|
||||||
|
<div class="title-wrapper">
|
||||||
|
<h1 class="header-title">
|
||||||
|
{{#if this.showPM}}
|
||||||
|
<a
|
||||||
|
class="private-message-glyph-wrapper"
|
||||||
|
href={{fn this.currentUser.pmPath @topic}}
|
||||||
|
aria-label={{i18n "user.messages.inbox"}}
|
||||||
|
>
|
||||||
|
{{icon "envelope" class="private-message-glyph"}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if (and @topic.fancyTitle @topic.url)}}
|
||||||
|
<Status @topic={{@topic}} @disableActions={{@disableActions}} />
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="topic-link"
|
||||||
|
{{on "click" this.jumpToTopPost}}
|
||||||
|
href={{@topic.url}}
|
||||||
|
data-topic-id={{@topic.id}}
|
||||||
|
>
|
||||||
|
<span>{{htmlSafe @topic.fancyTitle}}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="header-topic-title-suffix">
|
||||||
|
<PluginOutlet
|
||||||
|
@name="header-topic-title-suffix"
|
||||||
|
@outletArgs={{hash topic=@topic}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{#if (or @topic.details.loaded @topic.category)}}
|
||||||
|
{{#if
|
||||||
|
(and
|
||||||
|
@topic.category
|
||||||
|
(or
|
||||||
|
(not @topic.category.isUncategorizedCategory)
|
||||||
|
(not this.siteSettings.suppress_uncategorized_badge)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<div class="categories-wrapper">
|
||||||
|
{{#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}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="topic-header-extra">
|
||||||
|
{{htmlSafe this.tags}}
|
||||||
|
<div class="topic-header-participants">
|
||||||
|
{{#if this.showPM}}
|
||||||
|
{{#each this.participants as |participant|}}
|
||||||
|
<Participant
|
||||||
|
@user={{participant}}
|
||||||
|
@type={{if participant.username "user" "group"}}
|
||||||
|
{{! username for user, name for group }}
|
||||||
|
@username={{or participant.username participant.name}}
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{#if (gt this.totalParticipants this.maxExtraItems)}}
|
||||||
|
<a
|
||||||
|
class="more-participants"
|
||||||
|
{{on "click" this.jumpToTopPost}}
|
||||||
|
href={{@topic.url}}
|
||||||
|
data-topic-id={{@topic.id}}
|
||||||
|
>
|
||||||
|
+{{this.remainingParticipantCount}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{#if this.siteSettings.topic_featured_link_enabled}}
|
||||||
|
<FeaturedLink />
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class={{concat "trigger-" @type "-card"}}>
|
||||||
|
<a
|
||||||
|
class="icon"
|
||||||
|
{{on "click" this.click}}
|
||||||
|
href={{this.url}}
|
||||||
|
data-auto-route="true"
|
||||||
|
title={{@username}}
|
||||||
|
>
|
||||||
|
{{#if (eq @type "user")}}
|
||||||
|
{{avatar @user.avatar_template "tiny" (hash title=@username)}}
|
||||||
|
{{else}}
|
||||||
|
<span>
|
||||||
|
{{icon "users"}}
|
||||||
|
{{@username}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="topic-statuses">
|
||||||
|
{{#each this.topicStatuses as |status|}}
|
||||||
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
|
<span
|
||||||
|
class={{concatClass status.klass "topic-status"}}
|
||||||
|
{{on "click" this.togglePinnedForUser}}
|
||||||
|
>
|
||||||
|
{{icon status.icon.name class=status.icon.iconArgs.class}}
|
||||||
|
</span>
|
||||||
|
{{/each}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
id="current-user"
|
||||||
|
class={{concatClass
|
||||||
|
(if @active "active")
|
||||||
|
"header-dropdown-toggle current-user user-menu-panel"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="icon btn-flat"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={{@active}}
|
||||||
|
href={{this.currentUser.path}}
|
||||||
|
aria-label={{concat
|
||||||
|
(or this.currentUser.name this.currentUser.username)
|
||||||
|
(i18n "user.account_possessive")
|
||||||
|
}}
|
||||||
|
data-auto-route="true"
|
||||||
|
{{on "click" this.click}}
|
||||||
|
>
|
||||||
|
<Notifications @active={{@active}} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import {
|
||||||
|
addExtraUserClasses,
|
||||||
|
renderAvatar,
|
||||||
|
} from "discourse/helpers/user-avatar";
|
||||||
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
|
import UserTip from "../../user-tip";
|
||||||
|
import UserStatusBubble from "./user-status-bubble";
|
||||||
|
|
||||||
|
export default class Notifications extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
avatarSize = "medium";
|
||||||
|
|
||||||
|
get avatar() {
|
||||||
|
let avatarAttrs = {};
|
||||||
|
addExtraUserClasses(this.currentUser, avatarAttrs);
|
||||||
|
return htmlSafe(
|
||||||
|
renderAvatar(this.currentUser, {
|
||||||
|
imageSize: this.avatarSize,
|
||||||
|
alt: "user.avatar.header_title",
|
||||||
|
template: this.currentUser.avatar_template,
|
||||||
|
username: this.currentUser.username,
|
||||||
|
name: this.siteSettings.enable_names && this.currentUser.name,
|
||||||
|
...avatarAttrs,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get _shouldHighlightAvatar() {
|
||||||
|
return (
|
||||||
|
!this.currentUser.read_first_notification &&
|
||||||
|
!this.currentUser.enforcedSecondFactor &&
|
||||||
|
!this.args.active
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isInDoNotDisturb() {
|
||||||
|
return this.currentUser.isInDoNotDisturb();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{this.avatar}}
|
||||||
|
|
||||||
|
{{#if this._shouldHighlightAvatar}}
|
||||||
|
<UserTip
|
||||||
|
@id="first_notification"
|
||||||
|
@triggerSelector=".header-dropdown-toggle.current-user"
|
||||||
|
@placement="bottom-end"
|
||||||
|
@titleText={{i18n "user_tips.first_notification.title"}}
|
||||||
|
@contentText={{i18n "user_tips.first_notification.content"}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.currentUser.status}}
|
||||||
|
<UserStatusBubble
|
||||||
|
@timezone={{this.this.currentUser.user_option.timezone}}
|
||||||
|
@status={{this.currentUser.status}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isInDoNotDisturb}}
|
||||||
|
<div class="do-not-disturb-background">{{icon "moon"}}</div>
|
||||||
|
{{else}}
|
||||||
|
{{#if this.currentUser.new_personal_messages_notifications_count}}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="badge-notification with-icon new-pms"
|
||||||
|
title={{i18n
|
||||||
|
"notifications.tooltip.new_message_notification"
|
||||||
|
(hash
|
||||||
|
count=this.currentUser.new_personal_messages_notifications_count
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
aria-label={{i18n
|
||||||
|
"notifications.tooltip.new_message_notification"
|
||||||
|
(hash
|
||||||
|
count=this.currentUser.new_personal_messages_notifications_count
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{icon "envelope"}}
|
||||||
|
</a>
|
||||||
|
{{else if this.currentUser.unseen_reviewable_count}}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="badge-notification with-icon new-reviewables"
|
||||||
|
title={{i18n
|
||||||
|
"notifications.tooltip.new_reviewable"
|
||||||
|
(hash count=this.currentUser.unseen_reviewable_count)
|
||||||
|
}}
|
||||||
|
aria-label={{i18n
|
||||||
|
"notifications.tooltip.new_reviewable"
|
||||||
|
(hash count=this.currentUser.unseen_reviewable_count)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{icon "flag"}}
|
||||||
|
</a>
|
||||||
|
{{else if this.currentUser.all_unread_notifications_count}}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="badge-notification unread-notifications"
|
||||||
|
title={{i18n
|
||||||
|
"notifications.tooltip.regular"
|
||||||
|
(hash count=this.currentUser.all_unread_notifications_count)
|
||||||
|
}}
|
||||||
|
aria-label={{i18n
|
||||||
|
"user.notifications"
|
||||||
|
(hash count=this.currentUser.all_unread_notifications_count)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{this.currentUser.all_unread_notifications_count}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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 = <template>
|
||||||
|
<div class="user-status-background">
|
||||||
|
{{emoji
|
||||||
|
@status.emoji
|
||||||
|
(hash title=(title @status.description @status.ends_at @timezone))
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default UserStatusBubble;
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="user-menu-dropdown-wrapper"
|
||||||
|
{{closeOnClickOutside
|
||||||
|
this.clickOutside
|
||||||
|
(hash
|
||||||
|
targetSelector=".user-menu-panel"
|
||||||
|
secondaryTargetSelector=".user-menu-panel"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserMenu @closeUserMenu={{@toggleUserMenu}} />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class={{concatClass
|
||||||
|
(unless this.site.mobileView "drop-down-mode")
|
||||||
|
"d-header-wrap"
|
||||||
|
}}
|
||||||
|
{{didInsert this.setupHeader}}
|
||||||
|
>
|
||||||
|
<GlimmerHeader
|
||||||
|
@canSignUp={{@canSignUp}}
|
||||||
|
@showSidebar={{@showSidebar}}
|
||||||
|
@sidebarEnabled={{@sidebarEnabled}}
|
||||||
|
@toggleSidebar={{@toggleSidebar}}
|
||||||
|
@showCreateAccount={{@showCreateAccount}}
|
||||||
|
@showLogin={{@showLogin}}
|
||||||
|
@animateMenu={{this.animateMenu}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
|
@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#let
|
||||||
|
(component PanelPortal panelElement=this.panelElement)
|
||||||
|
as |panelPortal|
|
||||||
|
}}
|
||||||
|
<@component @panelPortal={{panelPortal}} />
|
||||||
|
{{/let}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
<div
|
<div
|
||||||
class={{concat-class "search-menu-panel menu-panel" @animationClass}}
|
class={{concat-class "menu-panel" @panelClass @animationClass}}
|
||||||
data-max-width="500"
|
data-max-width="500"
|
||||||
>
|
>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
<MenuPanel @animationClass={{this.animationClass}}>
|
<MenuPanel
|
||||||
|
@animationClass={{this.animationClass}}
|
||||||
|
@panelClass="search-menu-panel"
|
||||||
|
>
|
||||||
<SearchMenu
|
<SearchMenu
|
||||||
@onClose={{@closeSearchMenu}}
|
@onClose={{@closeSearchMenu}}
|
||||||
@inlineResults={{true}}
|
@inlineResults={{true}}
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
@clearSearch={{this.clearSearch}}
|
@clearSearch={{this.clearSearch}}
|
||||||
/>
|
/>
|
||||||
{{else if this.displayMenuPanelResults}}
|
{{else if this.displayMenuPanelResults}}
|
||||||
<MenuPanel>
|
<MenuPanel @panelClass="search-menu-panel">
|
||||||
<SearchMenu::Results
|
<SearchMenu::Results
|
||||||
@loading={{this.loading}}
|
@loading={{this.loading}}
|
||||||
@noResults={{this.noResults}}
|
@noResults={{this.noResults}}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
class="btn btn-default notifications-dismiss btn-icon-text"
|
class="btn btn-default notifications-dismiss btn-icon-text"
|
||||||
title={{this.dismissTitle}}
|
title={{this.dismissTitle}}
|
||||||
{{on "click" this.dismissButtonClick}}
|
{{on "click" this.dismissButtonClick}}
|
||||||
|
{{auto-focus}}
|
||||||
>
|
>
|
||||||
{{d-icon "check"}}
|
{{d-icon "check"}}
|
||||||
{{i18n "user.dismiss"}}
|
{{i18n "user.dismiss"}}
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { htmlSafe } from "@ember/template";
|
||||||
import { isEmpty } from "@ember/utils";
|
import { isEmpty } from "@ember/utils";
|
||||||
import { avatarImg } from "discourse-common/lib/avatar-utils";
|
import { avatarImg } from "discourse-common/lib/avatar-utils";
|
||||||
|
|
||||||
export default function boundAvatarTemplate(avatarTemplate, size) {
|
export default function boundAvatarTemplate(avatarTemplate, size, options) {
|
||||||
if (isEmpty(avatarTemplate)) {
|
if (isEmpty(avatarTemplate)) {
|
||||||
return htmlSafe("<div class='avatar-placeholder'></div>");
|
return htmlSafe("<div class='avatar-placeholder'></div>");
|
||||||
} else {
|
} else {
|
||||||
return htmlSafe(avatarImg({ size, avatarTemplate }));
|
return htmlSafe(avatarImg({ size, avatarTemplate, ...options }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,9 @@ import {
|
||||||
import { addPluginDocumentTitleCounter } from "discourse/components/d-document";
|
import { addPluginDocumentTitleCounter } from "discourse/components/d-document";
|
||||||
import { addToolbarCallback } from "discourse/components/d-editor";
|
import { addToolbarCallback } from "discourse/components/d-editor";
|
||||||
import { addCategorySortCriteria } from "discourse/components/edit-category-settings";
|
import { addCategorySortCriteria } from "discourse/components/edit-category-settings";
|
||||||
|
import { addCustomHeaderClass } from "discourse/components/glimmer-header";
|
||||||
|
import { addToHeaderIcons as addToGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons";
|
||||||
|
import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from "discourse/components/glimmer-site-header";
|
||||||
import { addGlobalNotice } from "discourse/components/global-notice";
|
import { addGlobalNotice } from "discourse/components/global-notice";
|
||||||
import { _addBulkButton } from "discourse/components/modal/topic-bulk-actions";
|
import { _addBulkButton } from "discourse/components/modal/topic-bulk-actions";
|
||||||
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
|
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
|
||||||
|
@ -142,7 +145,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
|
||||||
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
|
||||||
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
// using the format described at https://keepachangelog.com/en/1.0.0/.
|
||||||
|
|
||||||
export const PLUGIN_API_VERSION = "1.26.0";
|
export const PLUGIN_API_VERSION = "1.27.0";
|
||||||
|
|
||||||
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||||
function canModify(klass, type, resolverName, changes) {
|
function canModify(klass, type, resolverName, changes) {
|
||||||
|
@ -931,6 +934,10 @@ class PluginApi {
|
||||||
*
|
*
|
||||||
**/
|
**/
|
||||||
addHeaderPanel(name, toggle, transformAttrs) {
|
addHeaderPanel(name, toggle, transformAttrs) {
|
||||||
|
// deprecated(
|
||||||
|
// "addHeaderPanel has been removed. Use api.addToHeaderIcons instead.",
|
||||||
|
// { id: "discourse.add-header-panel" }
|
||||||
|
// );
|
||||||
attachAdditionalPanel(name, toggle, transformAttrs);
|
attachAdditionalPanel(name, toggle, transformAttrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1775,17 +1782,36 @@ class PluginApi {
|
||||||
addExtraIconRenderer(renderer);
|
addExtraIconRenderer(renderer);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Adds a widget to the header-icon ul. The widget must already be created. You can create new widgets
|
* Adds a widget or a component to the header-icon ul.
|
||||||
|
*
|
||||||
|
* If adding a widget it must already be created. You can create new widgets
|
||||||
* in a theme or plugin via an initializer prior to calling this function.
|
* in a theme or plugin via an initializer prior to calling this function.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* api.addToHeaderIcons(
|
* api.addToHeaderIcons(
|
||||||
* createWidget('some-widget')
|
* createWidget("some-widget")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* If adding a component you can pass the component directly. Additionally, you can
|
||||||
|
* utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when
|
||||||
|
* you want create a button in the header that opens a dropdown panel with additional content.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* api.addToHeaderIcons(
|
||||||
|
<template>
|
||||||
|
<span>Icon</span>
|
||||||
|
|
||||||
|
<@panelPortal>
|
||||||
|
<div>Panel</div>
|
||||||
|
</@panelPortal>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
**/
|
**/
|
||||||
addToHeaderIcons(icon) {
|
addToHeaderIcons(icon) {
|
||||||
addToHeaderIcons(icon);
|
addToHeaderIcons(icon);
|
||||||
|
addToGlimmerHeaderIcons(icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1965,6 +1991,7 @@ class PluginApi {
|
||||||
*/
|
*/
|
||||||
forceDropdownForMenuPanels(classNames) {
|
forceDropdownForMenuPanels(classNames) {
|
||||||
forceDropdownForMenuPanels(classNames);
|
forceDropdownForMenuPanels(classNames);
|
||||||
|
glimmerForceDropdownForMenuPanels(classNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2775,6 +2802,19 @@ class PluginApi {
|
||||||
addImageWrapperButton(label, btnClass, icon);
|
addImageWrapperButton(label, btnClass, icon);
|
||||||
addApiImageWrapperButtonClickEvent(fn);
|
addApiImageWrapperButtonClickEvent(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a custom css class to the header. The class or classes will live alongside the `d-header` class.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* api.addCustomHeaderClass("class-one");
|
||||||
|
* api.addCustomHeaderClass("class-two");
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
addCustomHeaderClass(klass) {
|
||||||
|
addCustomHeaderClass(klass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
||||||
|
|
|
@ -54,6 +54,7 @@ export default function renderTopicFeaturedLink(topic) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// deprecated per components/glimmer-header/topic/featured-link.gjs
|
||||||
export function topicFeaturedLinkNode(topic) {
|
export function topicFeaturedLinkNode(topic) {
|
||||||
const meta = extractLinkMeta(topic);
|
const meta = extractLinkMeta(topic);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Deprecated in favor of app/assets/javascripts/discourse/app/services/docking.js
|
||||||
import Mixin from "@ember/object/mixin";
|
import Mixin from "@ember/object/mixin";
|
||||||
import { cancel } from "@ember/runloop";
|
import { cancel } from "@ember/runloop";
|
||||||
import discourseDebounce from "discourse-common/lib/debounce";
|
import discourseDebounce from "discourse-common/lib/debounce";
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { registerDestructor } from "@ember/destroyable";
|
||||||
|
import Modifier from "ember-modifier";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
|
export default class CloseOnClickOutside extends Modifier {
|
||||||
|
constructor(owner, args) {
|
||||||
|
super(owner, args);
|
||||||
|
registerDestructor(this, (instance) => instance.cleanup());
|
||||||
|
}
|
||||||
|
|
||||||
|
modify(element, [closeFn, { targetSelector, secondaryTargetSelector }]) {
|
||||||
|
this.closeFn = closeFn;
|
||||||
|
this.element = element;
|
||||||
|
this.targetSelector = targetSelector;
|
||||||
|
this.secondaryTargetSelector = secondaryTargetSelector;
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", this.check, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
check(event) {
|
||||||
|
if (this.element.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.querySelector(this.targetSelector).contains(event.target) ||
|
||||||
|
(this.secondaryTargetSelector &&
|
||||||
|
document
|
||||||
|
.querySelector(this.secondaryTargetSelector)
|
||||||
|
?.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeFn(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
document.removeEventListener("pointerdown", this.check);
|
||||||
|
}
|
||||||
|
}
|
10
app/assets/javascripts/discourse/app/services/header.js
Normal file
10
app/assets/javascripts/discourse/app/services/header.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import Service from "@ember/service";
|
||||||
|
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||||
|
|
||||||
|
@disableImplicitInjections
|
||||||
|
export default class Header extends Service {
|
||||||
|
@tracked topic = null;
|
||||||
|
@tracked hamburgerVisible = false;
|
||||||
|
@tracked userVisible = false;
|
||||||
|
}
|
|
@ -9,18 +9,32 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{#if this.showSiteHeader}}
|
{{#if this.showSiteHeader}}
|
||||||
<SiteHeader
|
{{#if this.currentUser.glimmer_header_enabled}}
|
||||||
@canSignUp={{this.canSignUp}}
|
<GlimmerSiteHeader
|
||||||
@showCreateAccount={{route-action "showCreateAccount"}}
|
@canSignUp={{this.canSignUp}}
|
||||||
@showLogin={{route-action "showLogin"}}
|
@showCreateAccount={{route-action "showCreateAccount"}}
|
||||||
@showKeyboard={{route-action "showKeyboardShortcutsHelp"}}
|
@showLogin={{route-action "showLogin"}}
|
||||||
@toggleMobileView={{route-action "toggleMobileView"}}
|
@showKeyboard={{route-action "showKeyboardShortcutsHelp"}}
|
||||||
@logout={{route-action "logout"}}
|
@toggleMobileView={{route-action "toggleMobileView"}}
|
||||||
@sidebarEnabled={{this.sidebarEnabled}}
|
@logout={{route-action "logout"}}
|
||||||
@navigationMenuQueryParamOverride={{this.navigationMenuQueryParamOverride}}
|
@sidebarEnabled={{this.sidebarEnabled}}
|
||||||
@showSidebar={{this.showSidebar}}
|
@showSidebar={{this.showSidebar}}
|
||||||
@toggleSidebar={{action "toggleSidebar"}}
|
@toggleSidebar={{this.toggleSidebar}}
|
||||||
/>
|
/>
|
||||||
|
{{else}}
|
||||||
|
<SiteHeader
|
||||||
|
@canSignUp={{this.canSignUp}}
|
||||||
|
@showCreateAccount={{route-action "showCreateAccount"}}
|
||||||
|
@showLogin={{route-action "showLogin"}}
|
||||||
|
@showKeyboard={{route-action "showKeyboardShortcutsHelp"}}
|
||||||
|
@toggleMobileView={{route-action "toggleMobileView"}}
|
||||||
|
@logout={{route-action "logout"}}
|
||||||
|
@sidebarEnabled={{this.sidebarEnabled}}
|
||||||
|
@navigationMenuQueryParamOverride={{this.navigationMenuQueryParamOverride}}
|
||||||
|
@showSidebar={{this.showSidebar}}
|
||||||
|
@toggleSidebar={{action "toggleSidebar"}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<SoftwareUpdatePrompt />
|
<SoftwareUpdatePrompt />
|
||||||
|
@ -36,7 +50,6 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div id="main-outlet-wrapper" class="wrap" role="main">
|
<div id="main-outlet-wrapper" class="wrap" role="main">
|
||||||
|
|
||||||
<div class="sidebar-wrapper">
|
<div class="sidebar-wrapper">
|
||||||
{{! empty div allows for animation }}
|
{{! empty div allows for animation }}
|
||||||
{{#if (and this.sidebarEnabled this.showSidebar)}}
|
{{#if (and this.sidebarEnabled this.showSidebar)}}
|
||||||
|
@ -74,7 +87,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PluginOutlet @name="after-main-outlet" />
|
<PluginOutlet @name="after-main-outlet" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PluginOutlet
|
<PluginOutlet
|
||||||
|
|
|
@ -9,7 +9,9 @@ import { logSearchLinkClick } from "discourse/lib/search";
|
||||||
import DiscourseURL from "discourse/lib/url";
|
import DiscourseURL from "discourse/lib/url";
|
||||||
import { scrollTop } from "discourse/mixins/scroll-top";
|
import { scrollTop } from "discourse/mixins/scroll-top";
|
||||||
import { avatarImg } from "discourse/widgets/post";
|
import { avatarImg } from "discourse/widgets/post";
|
||||||
import RenderGlimmer from "discourse/widgets/render-glimmer";
|
import RenderGlimmer, {
|
||||||
|
registerWidgetShim,
|
||||||
|
} from "discourse/widgets/render-glimmer";
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
import { isTesting } from "discourse-common/config/environment";
|
import { isTesting } from "discourse-common/config/environment";
|
||||||
import getURL from "discourse-common/lib/get-url";
|
import getURL from "discourse-common/lib/get-url";
|
||||||
|
@ -18,6 +20,7 @@ import discourseLater from "discourse-common/lib/later";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
const SEARCH_BUTTON_ID = "search-button";
|
const SEARCH_BUTTON_ID = "search-button";
|
||||||
|
export const PANEL_WRAPPER_ID = "additional-panel-wrapper";
|
||||||
|
|
||||||
let _extraHeaderIcons = [];
|
let _extraHeaderIcons = [];
|
||||||
|
|
||||||
|
@ -231,6 +234,14 @@ createWidget("header-icons", {
|
||||||
services: ["search"],
|
services: ["search"],
|
||||||
tagName: "ul.icons.d-header-icons",
|
tagName: "ul.icons.d-header-icons",
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerWidgetShim(
|
||||||
|
"extra-icon",
|
||||||
|
"div.wrapper",
|
||||||
|
hbs`<LegacyHeaderIconShim @component={{@data.component}} />`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
html(attrs) {
|
html(attrs) {
|
||||||
if (this.siteSettings.login_required && !this.currentUser) {
|
if (this.siteSettings.login_required && !this.currentUser) {
|
||||||
return [];
|
return [];
|
||||||
|
@ -240,7 +251,11 @@ createWidget("header-icons", {
|
||||||
|
|
||||||
if (_extraHeaderIcons) {
|
if (_extraHeaderIcons) {
|
||||||
_extraHeaderIcons.forEach((icon) => {
|
_extraHeaderIcons.forEach((icon) => {
|
||||||
icons.push(this.attach(icon));
|
if (typeof icon === "string") {
|
||||||
|
icons.push(this.attach(icon));
|
||||||
|
} else {
|
||||||
|
icons.push(this.attach("extra-icon", { component: icon }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -542,6 +557,8 @@ export default createWidget("header", {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
panels.push(h(`div#${PANEL_WRAPPER_ID}`));
|
||||||
|
|
||||||
if (this.site.mobileView || this.site.narrowDesktopView) {
|
if (this.site.mobileView || this.site.narrowDesktopView) {
|
||||||
panels.push(this.attach("header-cloak"));
|
panels.push(this.attach("header-cloak"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Deprecated in favor of app/assets/javascripts/discourse/app/components/glimmer-header/sidebar-toggle.gjs
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
|
|
||||||
export default createWidget("sidebar-toggle", {
|
export default createWidget("sidebar-toggle", {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// deprecated in favor of app/components/glimmer-header/user-dropdown/user-status-bubble.gjs
|
||||||
|
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
cleanUpComposerUploadPreProcessor,
|
cleanUpComposerUploadPreProcessor,
|
||||||
} from "discourse/components/composer-editor";
|
} from "discourse/components/composer-editor";
|
||||||
import { clearToolbarCallbacks } from "discourse/components/d-editor";
|
import { clearToolbarCallbacks } from "discourse/components/d-editor";
|
||||||
|
import { clearExtraHeaderIcons as clearExtraGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons";
|
||||||
import { clearBulkButtons } from "discourse/components/modal/topic-bulk-actions";
|
import { clearBulkButtons } from "discourse/components/modal/topic-bulk-actions";
|
||||||
import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
|
import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
|
||||||
import { resetDecorators as resetPluginOutletDecorators } from "discourse/components/plugin-connector";
|
import { resetDecorators as resetPluginOutletDecorators } from "discourse/components/plugin-connector";
|
||||||
|
@ -223,6 +224,7 @@ export function testCleanup(container, app) {
|
||||||
clearToolbarCallbacks();
|
clearToolbarCallbacks();
|
||||||
resetNotificationTypeRenderers();
|
resetNotificationTypeRenderers();
|
||||||
resetSidebarPanels();
|
resetSidebarPanels();
|
||||||
|
clearExtraGlimmerHeaderIcons();
|
||||||
clearExtraHeaderIcons();
|
clearExtraHeaderIcons();
|
||||||
resetOnKeyUpCallbacks();
|
resetOnKeyUpCallbacks();
|
||||||
resetItemSelectCallbacks();
|
resetItemSelectCallbacks();
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// deprecated in favor of spec/system/header/site_header_spec.rb
|
||||||
|
|
||||||
import {
|
import {
|
||||||
click,
|
click,
|
||||||
render,
|
render,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// deprecated in favor of spec/system/header/header_spec.rb
|
||||||
|
|
||||||
import { click, render } from "@ember/test-helpers";
|
import { click, render } from "@ember/test-helpers";
|
||||||
import { hbs } from "ember-cli-htmlbars";
|
import { hbs } from "ember-cli-htmlbars";
|
||||||
import { module, test } from "qunit";
|
import { module, test } from "qunit";
|
||||||
|
|
|
@ -439,3 +439,13 @@ $mobile-avatar-height: 1.532em;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#additional-panel-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
// positions are relative to the .d-header .panel div
|
||||||
|
top: 100%; // directly underneath .panel
|
||||||
|
right: -10px; // 10px to the right of .panel - adjust as needed
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: var(--d-border-radius-large);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fade in header avatar + icons if topic title is not visible in mobile header
|
// Fade in header avatar + icons if topic title is not visible in mobile header
|
||||||
.panel {
|
.panel .icons {
|
||||||
animation: fadein 0.5s;
|
animation: fadein 0.5s;
|
||||||
@media (prefers-reduced-motion) {
|
@media (prefers-reduced-motion) {
|
||||||
animation-duration: 0s;
|
animation-duration: 0s;
|
||||||
|
|
|
@ -1816,6 +1816,10 @@ class User < ActiveRecord::Base
|
||||||
in_any_groups?(SiteSetting.experimental_new_new_view_groups_map)
|
in_any_groups?(SiteSetting.experimental_new_new_view_groups_map)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def glimmer_header_enabled?
|
||||||
|
in_any_groups?(SiteSetting.experimental_glimmer_header_groups_map)
|
||||||
|
end
|
||||||
|
|
||||||
def watched_precedence_over_muted
|
def watched_precedence_over_muted
|
||||||
if user_option.watched_precedence_over_muted.nil?
|
if user_option.watched_precedence_over_muted.nil?
|
||||||
SiteSetting.watched_precedence_over_muted
|
SiteSetting.watched_precedence_over_muted
|
||||||
|
|
|
@ -74,7 +74,8 @@ class CurrentUserSerializer < BasicUserSerializer
|
||||||
:new_new_view_enabled?,
|
:new_new_view_enabled?,
|
||||||
:use_experimental_topic_bulk_actions?,
|
:use_experimental_topic_bulk_actions?,
|
||||||
:use_experimental_topic_bulk_actions?,
|
:use_experimental_topic_bulk_actions?,
|
||||||
:use_admin_sidebar
|
:use_admin_sidebar,
|
||||||
|
:glimmer_header_enabled?
|
||||||
|
|
||||||
delegate :user_stat, to: :object, private: true
|
delegate :user_stat, to: :object, private: true
|
||||||
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
|
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
|
||||||
|
|
|
@ -2523,6 +2523,7 @@ en:
|
||||||
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
|
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
|
||||||
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
|
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
|
||||||
enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design."
|
enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design."
|
||||||
|
experimental_glimmer_header_groups: "EXPERIMENTAL: Render the site header as glimmer components."
|
||||||
|
|
||||||
experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
|
experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
|
||||||
admin_sidebar_enabled_groups: "EXPERIMENTAL: Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons."
|
admin_sidebar_enabled_groups: "EXPERIMENTAL: Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons."
|
||||||
|
|
|
@ -2328,6 +2328,12 @@ developer:
|
||||||
instrument_gc_stat_per_request:
|
instrument_gc_stat_per_request:
|
||||||
default: false
|
default: false
|
||||||
hidden: true
|
hidden: true
|
||||||
|
experimental_glimmer_header_groups:
|
||||||
|
client: true
|
||||||
|
type: group_list
|
||||||
|
list_type: compact
|
||||||
|
default: ""
|
||||||
|
allow_any: false
|
||||||
admin_sidebar_enabled_groups:
|
admin_sidebar_enabled_groups:
|
||||||
type: group_list
|
type: group_list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
|
|
|
@ -7,6 +7,10 @@ in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.27.0] - 2024-02-21
|
||||||
|
|
||||||
|
- Updated `addToHeaderIcons` to take a component instead of just a widget (masked a string). Additionally, you can can now utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when * you want create a button in the header that opens a dropdown panel with additional content.
|
||||||
|
|
||||||
## [1.26.0] - 2024-02-21
|
## [1.26.0] - 2024-02-21
|
||||||
|
|
||||||
- Added `renderBeforeWrapperOutlet` which is used for rendering components before the content of wrapper plugin outlets
|
- Added `renderBeforeWrapperOutlet` which is used for rendering components before the content of wrapper plugin outlets
|
||||||
|
|
211
spec/system/header_spec.rb
Normal file
211
spec/system/header_spec.rb
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe "Glimmer Header", type: :system do
|
||||||
|
fab!(:current_user) { Fabricate(:user) }
|
||||||
|
before { SiteSetting.experimental_glimmer_header_groups = Group::AUTO_GROUPS[:everyone] }
|
||||||
|
|
||||||
|
it "renders basics" do
|
||||||
|
visit "/"
|
||||||
|
expect(page).to have_css("header.d-header")
|
||||||
|
expect(page).to have_css("#site-logo")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "displays sign up / login buttons" do
|
||||||
|
visit "/"
|
||||||
|
expect(page).to have_css("button.sign-up-button")
|
||||||
|
expect(page).to have_css("button.login-button")
|
||||||
|
|
||||||
|
find("button.sign-up-button").click
|
||||||
|
expect(page).to have_css(".d-modal.create-account")
|
||||||
|
|
||||||
|
click_outside
|
||||||
|
|
||||||
|
find("button.login-button").click
|
||||||
|
expect(page).to have_css(".d-modal.login-modal")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "shows login button when login required" do
|
||||||
|
SiteSetting.login_required = true
|
||||||
|
|
||||||
|
visit "/"
|
||||||
|
expect(page).to have_css("button.login-button")
|
||||||
|
expect(page).to have_css("button.sign-up-button")
|
||||||
|
expect(page).not_to have_css("#search-button")
|
||||||
|
expect(page).not_to have_css("button.btn-sidebar-toggle")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders unread notifications count when user's notifications count is updated" do
|
||||||
|
Fabricate(
|
||||||
|
:notification,
|
||||||
|
user: current_user,
|
||||||
|
high_priority: true,
|
||||||
|
read: false,
|
||||||
|
created_at: 8.minutes.ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
expect(page).to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .unread-notifications",
|
||||||
|
text: "1",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't show pending reviewables count for non-legacy navigation menu" do
|
||||||
|
SiteSetting.navigation_menu = "sidebar"
|
||||||
|
current_user.update!(admin: true)
|
||||||
|
Fabricate(:reviewable)
|
||||||
|
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
expect(page).not_to have_selector(".hamburger-dropdown .badge-notification")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "closes revamped menu when clicking outside" do
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
find(".header-dropdown-toggle.current-user").click
|
||||||
|
expect(page).to have_selector(".user-menu.revamped")
|
||||||
|
find("header.d-header").click
|
||||||
|
expect(page).not_to have_selector(".user-menu.revamped")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets header's height css property" do
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
resize_element(".d-header", 90)
|
||||||
|
wait_for(timeout: 100) { get_computed_style_value(".d-header", "--header-offset") == "90px" }
|
||||||
|
expect(get_computed_style_value(".d-header", "--header-offset")).to eq("90px")
|
||||||
|
|
||||||
|
resize_element(".d-header", 60)
|
||||||
|
wait_for(timeout: 100) { get_computed_style_value(".d-header", "--header-offset") == "60px" }
|
||||||
|
expect(get_computed_style_value(".d-header", "--header-offset")).to eq("60px")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "moves focus between tabs using arrow keys" do
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
find(".header-dropdown-toggle.current-user").click
|
||||||
|
expect(active_element_id).to eq("user-menu-button-all-notifications")
|
||||||
|
|
||||||
|
find("##{active_element_id}").send_keys(:arrow_down)
|
||||||
|
expect(active_element_id).to eq("user-menu-button-replies")
|
||||||
|
|
||||||
|
4.times { find("##{active_element_id}").send_keys(:arrow_down) }
|
||||||
|
expect(active_element_id).to eq("user-menu-button-profile")
|
||||||
|
|
||||||
|
find("##{active_element_id}").send_keys(:arrow_down)
|
||||||
|
expect(active_element_id).to eq("user-menu-button-all-notifications")
|
||||||
|
|
||||||
|
find("##{active_element_id}").send_keys(:arrow_up)
|
||||||
|
expect(active_element_id).to eq("user-menu-button-profile")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "prioritizes new personal messages bubble over unseen reviewables and regular notifications bubbles" do
|
||||||
|
Fabricate(:private_message_notification, user: current_user)
|
||||||
|
Fabricate(
|
||||||
|
:notification,
|
||||||
|
user: current_user,
|
||||||
|
high_priority: true,
|
||||||
|
read: false,
|
||||||
|
created_at: 8.minutes.ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||||
|
)
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(page).to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||||
|
)
|
||||||
|
expect(page).to have_css(".d-icon-envelope")
|
||||||
|
expect(
|
||||||
|
find(".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms")[:title],
|
||||||
|
).to eq(I18n.t("js.notifications.tooltip.new_message_notification", count: 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "prioritizes unseen reviewables bubble over regular notifications" do
|
||||||
|
current_user.update!(admin: true)
|
||||||
|
Fabricate(:reviewable)
|
||||||
|
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||||
|
)
|
||||||
|
expect(page).to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||||
|
)
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "shows regular notifications bubble if there are neither new personal messages nor unseen reviewables" do
|
||||||
|
Fabricate.times(
|
||||||
|
3,
|
||||||
|
:notification,
|
||||||
|
user: current_user,
|
||||||
|
high_priority: true,
|
||||||
|
read: false,
|
||||||
|
created_at: 8.minutes.ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in(current_user)
|
||||||
|
visit "/"
|
||||||
|
expect(page).to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.unread-notifications",
|
||||||
|
text: "3",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
find(".header-dropdown-toggle.current-user .badge-notification.unread-notifications")[:title],
|
||||||
|
).to eq(I18n.t("js.notifications.tooltip.regular", count: 3))
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-reviewables",
|
||||||
|
)
|
||||||
|
expect(page).not_to have_selector(
|
||||||
|
".header-dropdown-toggle.current-user .badge-notification.with-icon.new-pms",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when logged in and login required" do
|
||||||
|
fab!(:current_user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
it "displays current user when logged in and login required" do
|
||||||
|
SiteSetting.login_required = true
|
||||||
|
sign_in(current_user)
|
||||||
|
|
||||||
|
visit "/"
|
||||||
|
expect(page).not_to have_css("button.login-button")
|
||||||
|
expect(page).not_to have_css("button.sign-up-button")
|
||||||
|
expect(page).to have_css("#search-button")
|
||||||
|
expect(page).to have_css("button.btn-sidebar-toggle")
|
||||||
|
expect(page).to have_css("#current-user")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get_computed_style_value(selector, property)
|
||||||
|
page.evaluate_script(
|
||||||
|
"window.getComputedStyle(document.querySelector('#{selector}')).getPropertyValue('#{property}')",
|
||||||
|
).strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def resize_element(selector, size)
|
||||||
|
page.evaluate_script("document.querySelector('#{selector}').style.height = '#{size}px'")
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_element_id
|
||||||
|
page.evaluate_script("document.activeElement.id")
|
||||||
|
end
|
||||||
|
|
||||||
|
def click_outside
|
||||||
|
find(".d-modal").click(x: 0, y: 0)
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user