import { html, render } from "/js/lib/lit-html.js"; import { effect } from "/js/signals.js"; import { auth } from "/js/auth.js"; import { sidebarTemplate } from "/js/templates/sidebar.template.js"; import { footerTemplate } from "/js/templates/footer.template.js"; import { eyeIconTemplate } from "/js/templates/icons/eyeIcon.template.js"; import { PLUGIN_PREVIEW_QUERY_PARAM } from "/js/plugins/pluginService.js"; // Coarse "what kind of page is the user looking at" categories, for the // overlay plugin slot's context-page attribute. There's no ready-made small // enum for this in the router — derived here from the route-pattern string // (router.js's $currentRoute.get().route) rather than touching every route // registration in index.html. const PAGE_KIND_ROUTES = { home: ["/", "/intent/compose"], notifications: ["/notifications"], chat: [ "/messages", "/messages/inbox", "/messages/:convoId", "/messages/:convoId/settings", ], thread: [ "/profile/:handleOrDid/post/:rkey", "/profile/:handleOrDid/post/:rkey/likes", "/profile/:handleOrDid/post/:rkey/quotes", "/profile/:handleOrDid/post/:rkey/reposts", ], profile: [ "/profile/:handleOrDid", "/profile", "/profile/:handleOrDid/followers", "/profile/:handleOrDid/following", "/profile/:handleOrDid/known-followers", "/profile/:handleOrDid/lists/:rkey", ], settings: [ "/settings", "/settings/appearance", "/settings/muted-words", "/settings/muted-accounts", "/settings/blocked-accounts", "/settings/advanced", "/settings/plugins", "/settings/plugins/:pluginId", ], discover: [ "/search", "/feeds", "/bookmarks", "/hashtag/:tag", "/profile/:handleOrDid/feed/:rkey", ], }; function derivePageKind(route) { for (const [kind, patterns] of Object.entries(PAGE_KIND_ROUTES)) { if (patterns.includes(route)) return kind; } return "other"; } function exitPluginPreview() { const url = new URL(window.location.href); url.searchParams.delete(PLUGIN_PREVIEW_QUERY_PARAM); window.location.assign(url.toString()); } function pluginPreviewBannerTemplate({ plugins }) { if (plugins.length === 0) return null; const links = []; plugins.forEach((plugin, index) => { if (index > 0) links.push(", "); links.push( html`${plugin.name}`, ); }); return html`
${eyeIconTemplate()}
Preview mode
You are currently previewing community ${links.length === 1 ? "plugin" : "plugins"}: ${links}. Changes you make to the page won't be saved.
`; } import { Layout } from "/js/router.js"; import "/js/components/animated-sidebar.js"; import "/js/components/plugin-slot.js"; import { OVERLAY_SLOT_NAME } from "/js/plugins/pluginService.js"; import "/js/context-provider.js"; export function mainLayoutTemplate({ isAuthenticated = true, currentUser, activeNavItem, numNotifications = 0, numChatNotifications = 0, onClickActiveNavItem, children, onClickComposeButton, pluginService, previewingPlugins = [], onLongPressProfile = null, groupChatLinkService, pageKind = "other", profileActor = null, pluginComponentContext, }) { return html` groupChatLinkService.handleAction( e.detail.actionType, e.detail.preview, )} >
${sidebarTemplate({ isAuthenticated, currentUser, activeNavItem, numNotifications, numChatNotifications, onClickActiveItem: onClickActiveNavItem, onClickComposeButton, pluginSidebarItems: pluginService.getSidebarItems(), onLongPressProfile, })}
${children}
${pluginPreviewBannerTemplate({ plugins: previewingPlugins })} ${footerTemplate({ isAuthenticated, currentUser, activeNavItem, numNotifications, numChatNotifications, onClickActiveItem: onClickActiveNavItem, onLongPressProfile, })}
`; } export class MainLayout extends Layout { #container = null; #disposeEffect = null; constructor(context, router) { super(); this.context = context; this.router = router; const pagesEl = document.createElement("div"); pagesEl.id = "pages"; this.slot = pagesEl; } mount(container) { if (this.#container) { throw new Error("MainLayout is already mounted"); } const { isAuthenticated, dataLayer, notificationService, chatNotificationService, postComposerService, accountSwitcherService, pluginService, groupChatLinkService, interactionHandlers, } = this.context; const pluginComponentContext = { isAuthenticated, dataLayer, pluginService, postInteractionHandler: interactionHandlers.postInteractionHandler, }; const { router, slot } = this; container.id = "main-layout"; this.#container = container; const onLongPressProfile = accountSwitcherService && auth.supportsMultipleAccounts() ? () => accountSwitcherService.openAccountSwitcherDialog() : null; // The active page may claim the click by cancelling the event const onClickActiveNavItem = () => { const event = new CustomEvent("active-nav-click", { cancelable: true }); this.dispatchEvent(event); if (!event.defaultPrevented) { window.scrollTo({ top: -1, behavior: "smooth" }); } }; this.#disposeEffect = effect(() => { const currentRoute = router.$currentRoute.get(); const layoutOptions = currentRoute?.options?.layoutOptions ?? {}; const currentUser = dataLayer.derived.$currentUser.get(); const activeNavItem = typeof layoutOptions.activeNavItem === "function" ? layoutOptions.activeNavItem(currentRoute.params) : (layoutOptions.activeNavItem ?? null); const pageKind = derivePageKind(currentRoute?.route ?? null); // Only meaningful on the profile page kind — the handle or DID of // whichever profile is being viewed, straight from the route param // (app.bsky's getProfile-family endpoints already accept either // interchangeably, so no extra resolution step is needed here). const profileActor = pageKind === "profile" ? (currentRoute?.params?.handleOrDid ?? null) : null; const previewingPlugins = pluginService.isPreviewMode ? pluginService.$pluginsInfo.get().filter((plugin) => plugin.loaded) : []; render( mainLayoutTemplate({ isAuthenticated, currentUser, activeNavItem, numNotifications: notificationService?.$numNotifications.get() ?? null, numChatNotifications: chatNotificationService?.$numNotifications.get() ?? null, onClickActiveNavItem, children: slot, onClickComposeButton: () => postComposerService.composePost({ currentUser }), pluginService, previewingPlugins, onLongPressProfile, groupChatLinkService, pageKind, profileActor, pluginComponentContext, }), container, ); }); } openSidebar() { this.#container?.querySelector("animated-sidebar")?.open(); } dispose() { this.#disposeEffect?.(); this.#disposeEffect = null; this.#container?.replaceChildren(); this.#container = null; } }