feat: let plugins add pages of their own with an entry in the navigation

This commit is contained in:
Linus Rath
2026-10-14 04:57:00 +02:00
parent b57fa64557
commit b8b7a4ed15
34 changed files with 639 additions and 1 deletions
@@ -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();
});
});
+26
View File
@@ -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 (
+69 -1
View File
@@ -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();
});
});
+165
View File
@@ -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>
);
}
+20
View File
@@ -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}/`);
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "خيارات أخرى"
},
"plugin_pages": {
"loading": "جارٍ تحميل الصفحة",
"not_available_title": "هذه الصفحة غير متاحة",
"not_available_description": "الإضافة التي أضافتها متوقفة أو لم تعد مثبتة.",
"back_to_mail": "العودة إلى البريد"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "گزینه‌های بیشتر"
},
"plugin_pages": {
"loading": "در حال بارگذاری صفحه",
"not_available_title": "این صفحه در دسترس نیست",
"not_available_description": "افزونه‌ای که آن را اضافه کرده خاموش است یا دیگر نصب نیست.",
"back_to_mail": "بازگشت به ایمیل"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4344,5 +4344,11 @@
},
"top_bar": {
"more": "אפשרויות נוספות"
},
"plugin_pages": {
"loading": "טוען את הדף",
"not_available_title": "הדף הזה אינו זמין",
"not_available_description": "התוסף שהוסיף אותו כבוי או שאינו מותקן עוד.",
"back_to_mail": "חזרה לדואר"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "その他のオプション"
},
"plugin_pages": {
"loading": "ページを読み込んでいます",
"not_available_title": "このページは利用できません",
"not_available_description": "このページを追加したプラグインがオフになっているか、インストールされていません。",
"back_to_mail": "メールに戻る"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "추가 옵션"
},
"plugin_pages": {
"loading": "페이지를 불러오는 중",
"not_available_title": "이 페이지를 사용할 수 없습니다",
"not_available_description": "이 페이지를 추가한 플러그인이 꺼져 있거나 더 이상 설치되어 있지 않습니다.",
"back_to_mail": "메일로 돌아가기"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "Бусад сонголт"
},
"plugin_pages": {
"loading": "Хуудсыг ачаалж байна",
"not_available_title": "Энэ хуудас боломжгүй байна",
"not_available_description": "Үүнийг нэмсэн залгаас унтарсан эсвэл суулгаагүй болсон байна.",
"back_to_mail": "Мэйл рүү буцах"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "Другие действия"
},
"plugin_pages": {
"loading": "Загрузка страницы",
"not_available_title": "Эта страница недоступна",
"not_available_description": "Плагин, который её добавил, выключен или больше не установлен.",
"back_to_mail": "Вернуться к почте"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "Інші дії"
},
"plugin_pages": {
"loading": "Завантаження сторінки",
"not_available_title": "Ця сторінка недоступна",
"not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.",
"back_to_mail": "Повернутися до пошти"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "更多選項"
},
"plugin_pages": {
"loading": "正在載入頁面",
"not_available_title": "此頁面無法使用",
"not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。",
"back_to_mail": "返回郵件"
}
}
+6
View File
@@ -4343,5 +4343,11 @@
},
"top_bar": {
"more": "更多选项"
},
"plugin_pages": {
"loading": "正在加载页面",
"not_available_title": "此页面不可用",
"not_available_description": "添加此页面的插件已关闭或已不再安装。",
"back_to_mail": "返回邮件"
}
}