mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: let plugins add pages of their own with an entry in the navigation
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { PluginPageApp } from "@/components/plugins/plugin-page-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
|
||||
// Pages plugins add with api.ui.setPages: /apps/<pluginId>/<pageId>[/<path>].
|
||||
// Anything after the page id reaches the plugin's `app-page` slot as `path`.
|
||||
// A catch-all rather than [pluginId]/[pageId] so the static Lite export can
|
||||
// prerender the bare route; plugins never run in Lite, so it only ever shows
|
||||
// the "not available" note there (lib/lite-static-params.ts).
|
||||
export const generateStaticParams = generateLiteSegmentParams;
|
||||
|
||||
function decodeOnce(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PluginPageRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <PluginPageApp linkSegments={(segments ?? []).map(decodeOnce)} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Every plugin page gets an entry in the navigation, after the built-in apps:
|
||||
// the rail (desktop), the bottom bar (phones) and Mountain View's app grid.
|
||||
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/apps/kanban/board',
|
||||
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
|
||||
}));
|
||||
|
||||
const { NavigationRail } = await import('../navigation-rail');
|
||||
const { MvTopBar } = await import('../mv-top-bar');
|
||||
const pages = await import('@/lib/plugin-sandbox/pages');
|
||||
const { useUpdateStore } = await import('@/stores/update-store');
|
||||
|
||||
beforeEach(() => {
|
||||
useUpdateStore.setState({ startPolling: vi.fn() } as never);
|
||||
});
|
||||
afterEach(() => pages.resetPages());
|
||||
|
||||
const register = () => act(() => pages.setPluginPages('kanban', 'Kanban', [
|
||||
{ id: 'board', title: 'Board', icon: 'LayoutGrid' },
|
||||
{ id: 'stats', title: 'Stats' },
|
||||
]));
|
||||
|
||||
describe.each(['vertical', 'horizontal'] as const)('navigation rail (%s)', (orientation) => {
|
||||
it('lists no plugin pages when none are registered', () => {
|
||||
const { container } = render(<NavigationRail orientation={orientation} />);
|
||||
expect(container.querySelector('[data-plugin-page-link]')).toBeNull();
|
||||
});
|
||||
|
||||
it('links each page after the built-in apps and marks the open one', () => {
|
||||
register();
|
||||
const { container } = render(<NavigationRail orientation={orientation} />);
|
||||
|
||||
const links = [...container.querySelectorAll('[data-plugin-page-link]')];
|
||||
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']);
|
||||
const mail = container.querySelector('a[href="/"]')!;
|
||||
expect(mail.compareDocumentPosition(links[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(links[0]).toHaveAttribute('aria-current', 'page');
|
||||
expect(links[1]).not.toHaveAttribute('aria-current');
|
||||
// Title in the entry (as label on the collapsed rail).
|
||||
expect(screen.getAllByText('Board').length + screen.queryAllByLabelText('Board').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mountain View app grid', () => {
|
||||
it('offers plugin pages next to the built-in apps', () => {
|
||||
register();
|
||||
const { container } = render(<MvTopBar />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'add_app' }));
|
||||
|
||||
const links = [...container.querySelectorAll('[data-plugin-page-link]')];
|
||||
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']);
|
||||
expect(links[0]).toHaveAttribute('role', 'menuitem');
|
||||
expect(screen.getByText('Stats')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
HelpCircle,
|
||||
Mail,
|
||||
Menu,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
@@ -32,6 +33,7 @@ import { cn } from "@/lib/utils";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
import type { ContactSuggestion } from "@/lib/search-suggestions";
|
||||
|
||||
/** The top bar is not about any one item: plugin clicks carry no context. */
|
||||
@@ -111,6 +113,7 @@ export function MvTopBar({
|
||||
// The same gate the navigation rail puts on its "add app" button.
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled"));
|
||||
const sidebarApps = useResolvedSidebarApps();
|
||||
const pluginPages = usePluginPages();
|
||||
const isStalwartAdmin = useStalwartAdmin();
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
// The navigation rail starts the update check, and this bar replaces it on
|
||||
@@ -330,6 +333,29 @@ export function MvTopBar({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{pluginPages.map((page) => {
|
||||
const PageIcon = iconForName(page.icon) ?? Puzzle;
|
||||
return (
|
||||
<Link
|
||||
key={`plugin-page:${page.pluginId}:${page.id}`}
|
||||
href={pluginPageHref(page)}
|
||||
role="menuitem"
|
||||
title={page.pluginName}
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
onClick={() => {
|
||||
if (activeAppId) onCloseInlineApp?.();
|
||||
setAppsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted",
|
||||
!activeAppId && isPluginPageActive(pathname, page) && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<PageIcon className="w-6 h-6 text-muted-foreground" />
|
||||
<span className="text-xs truncate max-w-full">{page.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = iconForName(app.icon);
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search } from "@/components/icons";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search, Puzzle } from "@/components/icons";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { iconForName } from "@/components/icons";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -27,6 +27,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -220,6 +221,8 @@ export function NavigationRail({
|
||||
// Operator-provided apps (#931) plus the user's own; the gate above only
|
||||
// removes the user's, so a pinned set still shows when custom apps are off.
|
||||
const visibleSidebarApps = useResolvedSidebarApps();
|
||||
// Pages plugins added (api.ui.setPages); listed after the built-in apps.
|
||||
const pluginPages = usePluginPages();
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const isStalwartAdmin = useStalwartAdmin();
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
@@ -382,6 +385,40 @@ export function NavigationRail({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Plugin pages, after the built-in apps */}
|
||||
{pluginPages.map((page) => {
|
||||
const isActive = !activeAppId && isPluginPageActive(pathname, page);
|
||||
const PageIcon = iconForName(page.icon) ?? Puzzle;
|
||||
return (
|
||||
<Link
|
||||
key={`plugin-page:${page.pluginId}:${page.id}`}
|
||||
href={pluginPageHref(page)}
|
||||
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
title={page.pluginName}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative",
|
||||
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
|
||||
)}>
|
||||
<PageIcon className="w-5 h-5" />
|
||||
{isActive && !mountainView && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{page.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps (per-app mobile visibility) */}
|
||||
{visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => {
|
||||
const AppIcon = iconForName(app.icon);
|
||||
@@ -554,6 +591,37 @@ export function NavigationRail({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Plugin pages, after the built-in apps */}
|
||||
{pluginPages.map((page) => {
|
||||
const isActive = !activeAppId && isPluginPageActive(pathname, page);
|
||||
const PageIcon = iconForName(page.icon) ?? Puzzle;
|
||||
return (
|
||||
<Link
|
||||
key={`plugin-page:${page.pluginId}:${page.id}`}
|
||||
href={pluginPageHref(page)}
|
||||
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
? "justify-center w-10 h-10"
|
||||
: "px-2.5 text-sm",
|
||||
"max-lg:min-h-[44px]",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={collapsed ? page.title : undefined}
|
||||
title={collapsed ? `${page.title} (${page.pluginName})` : page.pluginName}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
<PageIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
|
||||
{!collapsed && <span className="truncate">{page.title}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{visibleSidebarApps.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// The app-page slot: a plugin's page at /apps/<pluginId>/<pageId>, inside the
|
||||
// app's own frame.
|
||||
|
||||
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({
|
||||
NavigationRail: ({ orientation }: { orientation?: string }) => <nav data-testid={`rail-${orientation ?? 'vertical'}`} />,
|
||||
}));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/apps/kanban/board',
|
||||
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
|
||||
}));
|
||||
|
||||
const { PluginPageApp } = await import('../plugin-page-app');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
const pages = await import('@/lib/plugin-sandbox/pages');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { usePluginStore } = await import('@/stores/plugin-store');
|
||||
|
||||
function activate(id: string, slots: string[]) {
|
||||
const plugin: InstalledPlugin = {
|
||||
id, name: 'Kanban', version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: ['ui:app-page'], grantedPermissions: ['ui:app-page'], entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
registry.register({
|
||||
plugin, code: '', tier: 'untrusted',
|
||||
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
|
||||
slotOffers: slots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false })),
|
||||
hookDisposables: [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSlotInstance.mockClear();
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: {} as never });
|
||||
usePluginStore.setState({ initialized: true, initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const p of registry.all()) registry.deregister(p.plugin.id);
|
||||
pages.resetPages();
|
||||
});
|
||||
|
||||
describe('plugin pages', () => {
|
||||
it('fills the content area with the plugin page and hands it the rest of the path', async () => {
|
||||
activate('kanban', ['app-page']);
|
||||
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
|
||||
|
||||
const { container } = render(<PluginPageApp linkSegments={['kanban', 'board', 'cards', '42']} />);
|
||||
|
||||
expect(screen.getByTestId('rail-vertical')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-plugin-page="kanban/board"]')).not.toBeNull();
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; plugin: { id: string } };
|
||||
expect(opts.slot).toBe('app-page');
|
||||
expect(opts.plugin.id).toBe('kanban');
|
||||
expect(opts.extraProps).toEqual({ path: 'cards/42' });
|
||||
});
|
||||
|
||||
it('passes an empty path for the page itself', async () => {
|
||||
activate('kanban', ['app-page']);
|
||||
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
|
||||
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ path: '' });
|
||||
});
|
||||
|
||||
it('says the page is not available, with a way back to Mail, once the plugin is gone', () => {
|
||||
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
|
||||
expect(screen.getByText('not_available_title')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'back_to_mail' })).toHaveAttribute('href', '/');
|
||||
expect(createSlotInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the plugins to start before saying so', () => {
|
||||
usePluginStore.setState({ initialized: false });
|
||||
render(<PluginPageApp linkSegments={['kanban', 'board']} />);
|
||||
|
||||
expect(screen.queryByText('not_available_title')).toBeNull();
|
||||
expect(screen.getByLabelText('loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not mount another plugin page under a wrong page id', () => {
|
||||
activate('kanban', ['app-page']);
|
||||
act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
|
||||
render(<PluginPageApp linkSegments={['kanban', 'other']} />);
|
||||
|
||||
expect(screen.getByText('not_available_title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Loader2, Puzzle } from "@/components/icons";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { PluginIframeSlot } from "@/components/plugins/plugin-iframe-slot";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { findPage, subscribePages } from "@/lib/plugin-sandbox/pages";
|
||||
import { offersForSlot, subscribe as subscribeSlots } from "@/lib/plugin-sandbox/registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PluginPageAppProps {
|
||||
/** Path segments after `/apps`: `[pluginId, pageId, ...subPath]`. */
|
||||
linkSegments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A page a plugin added with api.ui.setPages, at /apps/<pluginId>/<pageId>.
|
||||
* The app's own frame (navigation rail, or Mountain View's top bar, and the
|
||||
* phone navigation) stays around it; the plugin's `app-page` slot fills the
|
||||
* content area and gets the rest of the path as `path`.
|
||||
*/
|
||||
export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
const t = useTranslations("plugin_pages");
|
||||
const [pluginId = "", pageId = "", ...rest] = linkSegments;
|
||||
const subPath = rest.join("/");
|
||||
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client,
|
||||
);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const mvShell = useMountainViewShell(isEmbedded);
|
||||
const pluginsReady = usePluginStore((s) => s.initialized);
|
||||
|
||||
const page = useSyncExternalStore(
|
||||
subscribePages,
|
||||
() => findPage(pluginId, pageId),
|
||||
() => null,
|
||||
);
|
||||
const offersPage = useSyncExternalStore(
|
||||
subscribeSlots,
|
||||
() => offersForSlot("app-page").some((o) => o.pluginId === pluginId),
|
||||
() => false,
|
||||
);
|
||||
useDocumentTitle(page?.title ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => setInitialCheckDone(true));
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
saveRedirectAfterLogin();
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Opened straight from a link or a reload: the plugins have not been
|
||||
// started yet on this page load (mail and settings do the same).
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) void usePluginStore.getState().initializePlugins();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const extraProps = useMemo(() => ({ path: subPath }), [subPath]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const available = !!page && offersPage;
|
||||
let content: React.ReactNode;
|
||||
if (available) {
|
||||
content = (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" data-plugin-page={`${pluginId}/${pageId}`}>
|
||||
<PluginIframeSlot key={`${pluginId}/${pageId}`} pluginId={pluginId} slot="app-page" extraProps={extraProps} />
|
||||
</div>
|
||||
);
|
||||
} else if (!pluginsReady) {
|
||||
content = (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" aria-label={t("loading")} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="max-w-sm text-center space-y-3">
|
||||
<Puzzle className="w-10 h-10 text-muted-foreground mx-auto" />
|
||||
<p className="text-sm font-medium text-foreground">{t("not_available_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("not_available_description")}</p>
|
||||
<Link href="/" className="inline-block text-sm text-primary hover:underline underline-offset-2">
|
||||
{t("back_to_mail")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
showMenuButton={false}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<div data-mv-card={mvShell ? "" : undefined} className={cn("flex flex-1 min-h-0 overflow-hidden", mvShell && "mb-4 mx-4")}>
|
||||
{!isMobile && !isEmbedded && !mvShell && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: "1px solid rgba(128, 128, 128, 0.3)" }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{inlineApp && <InlineAppView apps={loadedApps} activeAppId={inlineApp.id} onClose={closeInlineApp} />}
|
||||
<div className={cn("flex flex-col flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{content}
|
||||
</div>
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { allPages, subscribePages, type RegisteredPage } from '@/lib/plugin-sandbox/pages';
|
||||
|
||||
/** Pages plugins registered with api.ui.setPages, in install order. */
|
||||
export function usePluginPages(): RegisteredPage[] {
|
||||
return useSyncExternalStore(subscribePages, allPages, allPages);
|
||||
}
|
||||
|
||||
/** The in-app route of a plugin page: /apps/<pluginId>/<pageId>. */
|
||||
export function pluginPageHref(page: Pick<RegisteredPage, 'pluginId' | 'id'>): string {
|
||||
return `/apps/${encodeURIComponent(page.pluginId)}/${encodeURIComponent(page.id)}`;
|
||||
}
|
||||
|
||||
/** Whether `pathname` (locale already stripped) shows this page. */
|
||||
export function isPluginPageActive(pathname: string, page: Pick<RegisteredPage, 'pluginId' | 'id'>): boolean {
|
||||
const href = pluginPageHref(page);
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "خيارات أخرى"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "جارٍ تحميل الصفحة",
|
||||
"not_available_title": "هذه الصفحة غير متاحة",
|
||||
"not_available_description": "الإضافة التي أضافتها متوقفة أو لم تعد مثبتة.",
|
||||
"back_to_mail": "العودة إلى البريد"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Més opcions"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "S'està carregant la pàgina",
|
||||
"not_available_title": "Aquesta pàgina no està disponible",
|
||||
"not_available_description": "El connector que l'ha afegida està desactivat o ja no està instal·lat.",
|
||||
"back_to_mail": "Torna al correu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Další možnosti"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Načítání stránky",
|
||||
"not_available_title": "Tato stránka není k dispozici",
|
||||
"not_available_description": "Plugin, který ji přidal, je vypnutý nebo už není nainstalovaný.",
|
||||
"back_to_mail": "Zpět na poštu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Flere muligheder"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Indlæser side",
|
||||
"not_available_title": "Denne side er ikke tilgængelig",
|
||||
"not_available_description": "Det plugin, der tilføjede den, er slået fra eller ikke længere installeret.",
|
||||
"back_to_mail": "Tilbage til mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Weitere Optionen"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Seite wird geladen",
|
||||
"not_available_title": "Diese Seite ist nicht verfügbar",
|
||||
"not_available_description": "Das Plugin, das sie hinzugefügt hat, ist ausgeschaltet oder nicht mehr installiert.",
|
||||
"back_to_mail": "Zurück zu E-Mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "More options"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Loading page",
|
||||
"not_available_title": "This page is not available",
|
||||
"not_available_description": "The plugin that added it is turned off or no longer installed.",
|
||||
"back_to_mail": "Back to Mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Más opciones"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Cargando la página",
|
||||
"not_available_title": "Esta página no está disponible",
|
||||
"not_available_description": "El complemento que la añadió está desactivado o ya no está instalado.",
|
||||
"back_to_mail": "Volver al correo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "گزینههای بیشتر"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "در حال بارگذاری صفحه",
|
||||
"not_available_title": "این صفحه در دسترس نیست",
|
||||
"not_available_description": "افزونهای که آن را اضافه کرده خاموش است یا دیگر نصب نیست.",
|
||||
"back_to_mail": "بازگشت به ایمیل"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Plus d'options"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Chargement de la page",
|
||||
"not_available_title": "Cette page n'est pas disponible",
|
||||
"not_available_description": "Le plugin qui l'a ajoutée est désactivé ou n'est plus installé.",
|
||||
"back_to_mail": "Retour à la messagerie"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4344,5 +4344,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "אפשרויות נוספות"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "טוען את הדף",
|
||||
"not_available_title": "הדף הזה אינו זמין",
|
||||
"not_available_description": "התוסף שהוסיף אותו כבוי או שאינו מותקן עוד.",
|
||||
"back_to_mail": "חזרה לדואר"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "További lehetőségek"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Oldal betöltése",
|
||||
"not_available_title": "Ez az oldal nem érhető el",
|
||||
"not_available_description": "A bővítmény, amely hozzáadta, ki van kapcsolva, vagy már nincs telepítve.",
|
||||
"back_to_mail": "Vissza a levelekhez"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Altre opzioni"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Caricamento della pagina",
|
||||
"not_available_title": "Questa pagina non è disponibile",
|
||||
"not_available_description": "Il plugin che l'ha aggiunta è disattivato o non è più installato.",
|
||||
"back_to_mail": "Torna alla posta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "その他のオプション"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "ページを読み込んでいます",
|
||||
"not_available_title": "このページは利用できません",
|
||||
"not_available_description": "このページを追加したプラグインがオフになっているか、インストールされていません。",
|
||||
"back_to_mail": "メールに戻る"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "추가 옵션"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "페이지를 불러오는 중",
|
||||
"not_available_title": "이 페이지를 사용할 수 없습니다",
|
||||
"not_available_description": "이 페이지를 추가한 플러그인이 꺼져 있거나 더 이상 설치되어 있지 않습니다.",
|
||||
"back_to_mail": "메일로 돌아가기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Citas iespējas"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Ielādē lapu",
|
||||
"not_available_title": "Šī lapa nav pieejama",
|
||||
"not_available_description": "Spraudnis, kas to pievienoja, ir izslēgts vai vairs nav instalēts.",
|
||||
"back_to_mail": "Atpakaļ uz pastu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Бусад сонголт"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Хуудсыг ачаалж байна",
|
||||
"not_available_title": "Энэ хуудас боломжгүй байна",
|
||||
"not_available_description": "Үүнийг нэмсэн залгаас унтарсан эсвэл суулгаагүй болсон байна.",
|
||||
"back_to_mail": "Мэйл рүү буцах"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Flere alternativer"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Laster inn siden",
|
||||
"not_available_title": "Denne siden er ikke tilgjengelig",
|
||||
"not_available_description": "Tillegget som la den til, er slått av eller ikke lenger installert.",
|
||||
"back_to_mail": "Tilbake til e-post"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Meer opties"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Pagina laden",
|
||||
"not_available_title": "Deze pagina is niet beschikbaar",
|
||||
"not_available_description": "De plug-in die hem heeft toegevoegd, staat uit of is niet meer geïnstalleerd.",
|
||||
"back_to_mail": "Terug naar e-mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Więcej opcji"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Wczytywanie strony",
|
||||
"not_available_title": "Ta strona jest niedostępna",
|
||||
"not_available_description": "Wtyczka, która ją dodała, jest wyłączona lub nie jest już zainstalowana.",
|
||||
"back_to_mail": "Wróć do poczty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Mais opções"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Carregando a página",
|
||||
"not_available_title": "Esta página não está disponível",
|
||||
"not_available_description": "O plugin que a adicionou está desativado ou não está mais instalado.",
|
||||
"back_to_mail": "Voltar ao e-mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Mai multe opțiuni"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Se încarcă pagina",
|
||||
"not_available_title": "Această pagină nu este disponibilă",
|
||||
"not_available_description": "Pluginul care a adăugat-o este dezactivat sau nu mai este instalat.",
|
||||
"back_to_mail": "Înapoi la e-mail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Другие действия"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Загрузка страницы",
|
||||
"not_available_title": "Эта страница недоступна",
|
||||
"not_available_description": "Плагин, который её добавил, выключен или больше не установлен.",
|
||||
"back_to_mail": "Вернуться к почте"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Ďalšie možnosti"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Načítava sa stránka",
|
||||
"not_available_title": "Táto stránka nie je k dispozícii",
|
||||
"not_available_description": "Doplnok, ktorý ju pridal, je vypnutý alebo už nie je nainštalovaný.",
|
||||
"back_to_mail": "Späť na poštu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Diğer seçenekler"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Sayfa yükleniyor",
|
||||
"not_available_title": "Bu sayfa kullanılamıyor",
|
||||
"not_available_description": "Bu sayfayı ekleyen eklenti kapalı veya artık yüklü değil.",
|
||||
"back_to_mail": "Postaya dön"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "Інші дії"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Завантаження сторінки",
|
||||
"not_available_title": "Ця сторінка недоступна",
|
||||
"not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.",
|
||||
"back_to_mail": "Повернутися до пошти"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "更多選項"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "正在載入頁面",
|
||||
"not_available_title": "此頁面無法使用",
|
||||
"not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。",
|
||||
"back_to_mail": "返回郵件"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4343,5 +4343,11 @@
|
||||
},
|
||||
"top_bar": {
|
||||
"more": "更多选项"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "正在加载页面",
|
||||
"not_available_title": "此页面不可用",
|
||||
"not_available_description": "添加此页面的插件已关闭或已不再安装。",
|
||||
"back_to_mail": "返回邮件"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user