fix: say when a message needs a connection in a browser that keeps no mail offline

This commit is contained in:
Linus Rath
2026-11-04 11:51:47 +01:00
parent 267e668137
commit e9e5a43391
37 changed files with 520 additions and 5 deletions
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { useTranslations } from "next-intl";
import { CloudOff, RefreshCw } from "@/components/icons";
import { Button } from "@/components/ui/button";
/**
* The reading pane when a message couldn't be fetched: offline (in a browser
* that keeps no mail on the device), or another failure. A short line and
* Retry, instead of an empty pane that says nothing.
*/
export function ReaderOpenFailure({ offline, onRetry }: { offline: boolean; onRetry: () => void }) {
const t = useTranslations("mail_p2_desk");
return (
<div role="status" data-reader-open-failure={offline ? "offline" : "failed"} className="m-auto flex max-w-sm flex-col items-center gap-4 p-8 text-center">
<CloudOff className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-foreground">{t(offline ? "reader_offline" : "reader_failed")}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="me-2 h-4 w-4" aria-hidden="true" />
{t("retry")}
</Button>
</div>
);
}
@@ -0,0 +1,179 @@
import React, { Suspense, lazy } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { clearAllHooks } from '@/lib/plugin-hooks';
import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api';
import { resetOpenEmail } from '@/lib/plugin-sandbox/open-email';
import type { InstalledPlugin } from '@/lib/plugin-types';
// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, a click
// on a message while offline left "No conversation selected" and the keys did
// nothing. The reader now says the message needs a connection, with Retry, and
// a key that changes mail says you're offline. The message is opened the way a
// plugin opens one, which goes through the same open as a click.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ? <div>reader</div> : <div>{props.emptyPaneContent ?? 'empty pane'}</div>),
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox {
return {
id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared,
sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
} as Mailbox;
}
// The active login's folders: its own account `cf` and the group `ce`.
const folders = [mailbox('a', 'inbox', 'cf'), mailbox('a', 'inbox', 'ce', true)];
// The group's Inbox message, as Email/get gives it.
const groupMessage = {
id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z',
keywords: { $seen: true }, mailboxIds: { a: true }, bodyValues: {},
} as unknown as Email;
let client: Record<string, unknown>;
const plugin = {
id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
} as InstalledPlugin;
/** The group account has `eaaaaab`, the own account nothing. */
let failing = true;
const getEmail = vi.fn(async (id: string, accountId?: string) => {
// Offline the real client logs the failure and answers with no message.
if (failing && navigator.onLine === false) return null;
if (failing) throw new TypeError('Failed to fetch');
return id === 'eaaaaab' && accountId === 'ce' ? { ...groupMessage } : null;
});
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
useToastStore.setState({ toasts: [] });
clearAllHooks();
resetOpenEmail();
getEmail.mockClear();
client = {
getAccountId: () => 'cf', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getMailboxes: vi.fn().mockResolvedValue(folders),
getEmail,
getSharedAccounts: () => ['cf', 'ce'].map((id) => ({ id, name: id, isPrimary: id === 'cf', capabilities: { mail: true } })),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null, mailboxes: folders,
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
selectedEmailKeys: new Set(),
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const setOnline = (online: boolean) => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
window.dispatchEvent(new Event(online ? 'online' : 'offline'));
};
describe('a message that cannot be fetched', () => {
afterEach(() => setOnline(true));
it('says offline that it needs a connection, and Retry opens it once back online', async () => {
failing = true;
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
act(() => setOnline(false));
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
expect(await screen.findByText('reader_offline')).toBeTruthy();
failing = false;
act(() => setOnline(true));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
await new Promise((r) => setTimeout(r, 20));
});
expect(await screen.findByText('reader')).toBeTruthy();
expect(screen.queryByText('reader_offline')).toBeNull();
});
it('says it could not be loaded when online', async () => {
failing = true;
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
expect(await screen.findByText('reader_failed')).toBeTruthy();
});
it('answers a key that changes mail with a note that it is offline', async () => {
render(<MailApp />);
await screen.findByText('message list', {}, { timeout: 5000 });
act(() => setOnline(false));
failing = true;
await act(async () => {
await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
});
await screen.findByText('reader_offline');
// No Archive folder here: Delete stands in for every key that changes mail.
fireEvent.keyDown(document.body, { key: '#', code: 'Digit3', shiftKey: true });
expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('action_offline');
});
});
+45 -5
View File
@@ -57,7 +57,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { useOfflineStore } from "@/lib/offline/state";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { useConnectionState } from "@/hooks/use-connection-state";
import { ReaderOpenFailure } from "@/components/email/reader-open-failure";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
@@ -65,6 +67,7 @@ import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-quer
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { guardOfflineMailChanges } from "@/lib/offline-key-guard";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
@@ -257,6 +260,11 @@ const UNIFIED_LIST_PUSH = {
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean };
/** No network, or this login's mail server can't be reached. */
function offlineNow(client: { getServerUrl?: () => string }): boolean {
return (typeof navigator !== 'undefined' && navigator.onLine === false) || isOfflineNow(client.getServerUrl?.());
}
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
@@ -324,6 +332,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// A message that couldn't be fetched: the reader says so, with Retry (mail-p2-7).
const [openFailure, setOpenFailure] = useState<{ target: { id: string } & Partial<Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>>; offline: boolean } | null>(null);
const tP2Desk = useTranslations('mail_p2_desk');
// The message the palette's "Move to…" was picked for.
const [moveDialogEmail, setMoveDialogEmail] = useState<Email | null>(null);
// The empty reading pane's slot for a selection; the list draws into it (mail-10).
@@ -1306,14 +1317,27 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return state.tabs.find((tab) => tab.id === id)?.kind === 'calendar';
});
const mailShortcutsApply = !isEmbedded || isFocusedProTab;
// Offline with no mail kept on this device, a key that changes mail can't
// run: say so rather than do nothing (mail-p2-7).
const connection = useConnectionState();
const canChangeMailRef = useRef(connection.canChangeMail);
canChangeMailRef.current = connection.canChangeMail;
const shortcutHandlers = useMemo(
() => guardOfflineMailChanges(keyboardHandlers, () => canChangeMailRef.current, () => toast.info(tP2Desk('action_offline'))),
// eslint-disable-next-line react-hooks/exhaustive-deps -- tP2Desk is stable per locale
[keyboardHandlers],
);
// Another folder: the line about the message that didn't open goes.
useEffect(() => setOpenFailure(null), [selectedMailbox]);
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer && mailShortcutsApply,
singleKeyShortcuts,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
// The message that didn't open is still what the keys are about.
selectedEmailId: selectedEmail?.id ?? openFailure?.target.id,
selectionCount: selectedEmailKeys.size,
mountainViewKeys: mvShell,
handlers: keyboardHandlers,
handlers: shortcutHandlers,
});
const anyTabHandlers = useMemo(() => ({
onShowHelp: keyboardHandlers.onShowHelp,
@@ -4953,6 +4977,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
): Promise<OpenEmailOutcome> => {
if (!client || !email) return new Error('the mail page is not ready');
setCursorOnly(false);
setOpenFailure(null);
// If composing, close the composer the way its X does: it keeps the
// draft and says so. Mountain View's compose window stays open while
@@ -5077,11 +5102,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mark-as-read logic is now handled by useEffect
return 'opened';
}
// The client answers a failed fetch with no message. Offline that
// says nothing about the message: the reader says it needs a connection.
if (offlineNow(client)) {
abandonOpenEmail(null);
setOpenFailure({ target: email, offline: true });
return new Error('offline');
}
abandonOpenEmail(previousEmail);
return 'missing';
} catch (error) {
console.error('Failed to fetch email content:', error);
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
if (openSeq === emailOpenSeqRef.current) {
// Offline the reader says why instead of going blank or back to the
// last message; another failure says so too when there's nothing to go back to.
const offline = offlineNow(client);
abandonOpenEmail(offline ? null : previousEmail);
if (offline || !previousEmail) setOpenFailure({ target: email, offline });
}
return error instanceof Error ? error : new Error(String(error));
}
};
@@ -6217,7 +6255,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
? <div ref={setSelectionPaneHost} className="m-auto p-8" />
: undefined}
: openFailure && !isLoadingEmail
? <ReaderOpenFailure offline={openFailure.offline} onRetry={() => void handleEmailSelect(openFailure.target)} />
: undefined}
emptyPaneFooter={emptyPaneBesideList ? <WelcomeBanner variant="pane" /> : undefined}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
@@ -0,0 +1,48 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { OfflineBanner } from '../offline-banner';
import { useAuthStore } from '@/stores/auth-store';
import { useOfflineStore } from '@/lib/offline/state';
// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, going
// offline showed no bar anywhere; a click on a message then did nothing.
const setOnline = (online: boolean) => {
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
window.dispatchEvent(new Event(online ? 'online' : 'offline'));
};
beforeEach(() => {
useAuthStore.setState({ activeAccountId: 'acc', serverUrl: 'https://mail.example.org' });
useOfflineStore.setState({ enabled: [], outbox: [], conflicts: [], areaConflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
});
afterEach(() => {
cleanup();
setOnline(true);
});
describe('the offline bar for an account that keeps no mail here', () => {
it('says that reading and changing mail needs a connection, and goes once online', () => {
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
act(() => setOnline(false));
expect(screen.getByTestId('offline-banner-no-store').textContent).toBe('offline_bar');
act(() => setOnline(true));
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
});
it('leaves an account with offline mode its own bar', () => {
useOfflineStore.setState({ enabled: ['acc'], browserOffline: true });
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
expect(screen.getByTestId('offline-banner')).toBeTruthy();
});
it('shows nothing when signed out', () => {
useAuthStore.setState({ activeAccountId: null });
act(() => setOnline(false));
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
});
});
+20
View File
@@ -6,6 +6,7 @@ import { CloudOff, X } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { useConnectionState } from '@/hooks/use-connection-state';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
@@ -20,6 +21,9 @@ import { OutboxList } from './outbox-list';
*
* It can be hidden until what it says changes (going offline again, another
* message waiting), for this session of the window.
*
* An account that keeps no mail here gets a one-line bar while offline too,
* so a message that doesn't open isn't a mystery (mail-p2-7).
*/
const HIDDEN_KEY = 'bulwark.offlineBanner.hidden';
@@ -49,7 +53,23 @@ export function OfflineBanner() {
const [hidden, setHidden] = useState<string | null>(hiddenFor);
const [banner, setBanner] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(banner);
const connection = useConnectionState();
const tP2 = useTranslations('mail_p2_desk');
const offline = browserOffline || serverDown;
if (!active) {
if (!account || !connection.offline) return null;
return (
<div
role="status"
data-testid="offline-banner-no-store"
data-window-top-row=""
className="flex shrink-0 items-center gap-2 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
<CloudOff className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate" title={tP2('offline_bar')}>{tP2('offline_bar')}</span>
</div>
);
}
const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
const hide = () => {
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { guardOfflineMailChanges } from '../offline-key-guard';
describe('keys that change mail, offline', () => {
it('answer with the refusal instead of running, and run again once they can', () => {
let can = false;
const archive = vi.fn();
const next = vi.fn();
const refused = vi.fn();
const handlers = guardOfflineMailChanges({ onArchive: archive, onNextEmail: next }, () => can, refused);
handlers.onArchive();
expect(archive).not.toHaveBeenCalled();
expect(refused).toHaveBeenCalledTimes(1);
handlers.onNextEmail();
expect(next).toHaveBeenCalledTimes(1);
can = true;
handlers.onArchive();
expect(archive).toHaveBeenCalledTimes(1);
expect(refused).toHaveBeenCalledTimes(1);
});
it('leaves a key with no handler without one', () => {
const handlers = guardOfflineMailChanges({ onArchive: undefined as (() => void) | undefined }, () => false, vi.fn());
expect(handlers.onArchive).toBeUndefined();
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Keys that change mail on the server: archive, delete, read/unread, spam,
* star. Offline with no mail kept on the device they can't run, and before
* this they did nothing at all.
*/
export const MAIL_CHANGE_SHORTCUTS = ['onArchive', 'onDelete', 'onMarkAsRead', 'onMarkAsUnread', 'onToggleSpam', 'onToggleStar'] as const;
type Handler = (...args: never[]) => unknown;
/**
* The same handlers, with those keys answering `onRefused` instead while
* `canChangeMail()` is false. A key with no handler stays without one.
*/
export function guardOfflineMailChanges<T extends object>(handlers: T, canChangeMail: () => boolean, onRefused: () => void): T {
const guarded = { ...handlers } as Record<string, unknown>;
for (const key of MAIL_CHANGE_SHORTCUTS) {
const handler = (handlers as Record<string, unknown>)[key] as Handler | undefined;
if (typeof handler !== 'function') continue;
guarded[key] = (...args: never[]) => {
if (!canChangeMail()) {
onRefused();
return undefined;
}
return handler(...args);
};
}
return guarded as T;
}
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "البحث في البريد عن «{text}»"
},
"mail_p2_desk": {
"reader_offline": "أنت غير متصل. تحتاج هذه الرسالة إلى اتصال لفتحها.",
"reader_failed": "تعذّر تحميل هذه الرسالة.",
"retry": "إعادة المحاولة",
"action_offline": "أنت غير متصل. يحتاج هذا إلى اتصال.",
"offline_bar": "أنت غير متصل. قراءة البريد وتغييره يحتاجان إلى اتصال.",
"offline_default_on": "يُحفظ بريدك على هذا الجهاز، لذا يمكنك قراءته دون اتصال."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Cerca «{text}» al correu"
},
"mail_p2_desk": {
"reader_offline": "No tens connexió. Aquest missatge necessita connexió per obrir-se.",
"reader_failed": "No s'ha pogut carregar aquest missatge.",
"retry": "Torna-ho a provar",
"action_offline": "No tens connexió. Això necessita connexió.",
"offline_bar": "No tens connexió. Llegir i canviar el correu necessita connexió.",
"offline_default_on": "El teu correu es guarda en aquest dispositiu, així el pots llegir sense connexió."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Hledat v poště „{text}“"
},
"mail_p2_desk": {
"reader_offline": "Jste offline. Tato zpráva se otevře jen s připojením.",
"reader_failed": "Tuto zprávu se nepodařilo načíst.",
"retry": "Zkusit znovu",
"action_offline": "Jste offline. K tomu je potřeba připojení.",
"offline_bar": "Jste offline. Ke čtení a úpravám pošty je potřeba připojení.",
"offline_default_on": "Vaše pošta je uložena v tomto zařízení, takže ji můžete číst offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Søg efter “{text}” i mail"
},
"mail_p2_desk": {
"reader_offline": "Du er offline. Denne besked kræver forbindelse for at åbne.",
"reader_failed": "Beskeden kunne ikke indlæses.",
"retry": "Prøv igen",
"action_offline": "Du er offline. Det kræver forbindelse.",
"offline_bar": "Du er offline. Læsning og ændring af mail kræver forbindelse.",
"offline_default_on": "Din mail gemmes på denne enhed, så du kan læse den offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "E-Mails nach „{text}“ durchsuchen"
},
"mail_p2_desk": {
"reader_offline": "Sie sind offline. Diese Nachricht lässt sich nur mit Verbindung öffnen.",
"reader_failed": "Diese Nachricht konnte nicht geladen werden.",
"retry": "Erneut versuchen",
"action_offline": "Sie sind offline. Dafür braucht es eine Verbindung.",
"offline_bar": "Sie sind offline. Zum Lesen und Ändern von E-Mails braucht es eine Verbindung.",
"offline_default_on": "Ihre E-Mails werden auf diesem Gerät gespeichert, damit Sie sie auch offline lesen können."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Search mail for \"{text}\""
},
"mail_p2_desk": {
"reader_offline": "You're offline. This message needs a connection to open.",
"reader_failed": "This message couldn't be loaded.",
"retry": "Try again",
"action_offline": "You're offline. This needs a connection.",
"offline_bar": "You're offline. Reading and changing mail needs a connection.",
"offline_default_on": "Your mail is kept on this device, so you can read it offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Buscar «{text}» en el correo"
},
"mail_p2_desk": {
"reader_offline": "No tienes conexión. Este mensaje necesita conexión para abrirse.",
"reader_failed": "No se ha podido cargar este mensaje.",
"retry": "Reintentar",
"action_offline": "No tienes conexión. Esto necesita conexión.",
"offline_bar": "No tienes conexión. Leer y cambiar el correo necesita conexión.",
"offline_default_on": "Tu correo se guarda en este dispositivo, así puedes leerlo sin conexión."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Otsi kirjadest „{text}”"
},
"mail_p2_desk": {
"reader_offline": "Oled võrguühenduseta. Selle kirja avamiseks on vaja ühendust.",
"reader_failed": "Seda kirja ei saanud laadida.",
"retry": "Proovi uuesti",
"action_offline": "Oled võrguühenduseta. Selleks on vaja ühendust.",
"offline_bar": "Oled võrguühenduseta. Kirjade lugemiseks ja muutmiseks on vaja ühendust.",
"offline_default_on": "Sinu kirjad hoitakse selles seadmes, nii et saad neid ka ühenduseta lugeda."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "جستجوی «{text}» در نامه‌ها"
},
"mail_p2_desk": {
"reader_offline": "آفلاین هستید. برای باز کردن این پیام اتصال لازم است.",
"reader_failed": "این پیام بارگیری نشد.",
"retry": "تلاش دوباره",
"action_offline": "آفلاین هستید. برای این کار اتصال لازم است.",
"offline_bar": "آفلاین هستید. برای خواندن و تغییر ایمیل اتصال لازم است.",
"offline_default_on": "ایمیل‌های شما روی این دستگاه نگه داشته می‌شود تا آفلاین هم بخوانید."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Rechercher « {text} » dans les messages"
},
"mail_p2_desk": {
"reader_offline": "Vous êtes hors ligne. Ce message a besoin d'une connexion pour s'ouvrir.",
"reader_failed": "Ce message n'a pas pu être chargé.",
"retry": "Réessayer",
"action_offline": "Vous êtes hors ligne. Cette action a besoin d'une connexion.",
"offline_bar": "Vous êtes hors ligne. Lire et modifier les e-mails demande une connexion.",
"offline_default_on": "Vos e-mails sont gardés sur cet appareil : vous pouvez les lire hors ligne."
},
"desktopMenuBar": {
+5
View File
@@ -7050,6 +7050,11 @@
"context_search_mail": "חיפוש “{text}” בדואר"
},
"mail_p2_desk": {
"reader_offline": "אין חיבור. כדי לפתוח הודעה זו צריך חיבור.",
"reader_failed": "לא ניתן היה לטעון הודעה זו.",
"retry": "לנסות שוב",
"action_offline": "אין חיבור. לפעולה זו צריך חיבור.",
"offline_bar": "אין חיבור. כדי לקרוא ולשנות דואר צריך חיבור.",
"offline_default_on": "הדואר שלך נשמר במכשיר הזה, כך שאפשר לקרוא אותו גם בלי חיבור."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "„{text}” keresése a levelek között"
},
"mail_p2_desk": {
"reader_offline": "Nincs kapcsolat. Ennek az üzenetnek a megnyitásához kapcsolat kell.",
"reader_failed": "Az üzenetet nem sikerült betölteni.",
"retry": "Újra",
"action_offline": "Nincs kapcsolat. Ehhez kapcsolat kell.",
"offline_bar": "Nincs kapcsolat. A levelek olvasásához és módosításához kapcsolat kell.",
"offline_default_on": "A leveleid ezen az eszközön is megmaradnak, így kapcsolat nélkül is olvashatod őket."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Cerca «{text}» nella posta"
},
"mail_p2_desk": {
"reader_offline": "Sei offline. Questo messaggio ha bisogno di una connessione per aprirsi.",
"reader_failed": "Impossibile caricare questo messaggio.",
"retry": "Riprova",
"action_offline": "Sei offline. Serve una connessione.",
"offline_bar": "Sei offline. Per leggere e modificare la posta serve una connessione.",
"offline_default_on": "La tua posta è conservata su questo dispositivo, così puoi leggerla offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "メールで「{text}」を検索"
},
"mail_p2_desk": {
"reader_offline": "オフラインです。このメッセージを開くには接続が必要です。",
"reader_failed": "このメッセージを読み込めませんでした。",
"retry": "再試行",
"action_offline": "オフラインです。この操作には接続が必要です。",
"offline_bar": "オフラインです。メールの閲覧や変更には接続が必要です。",
"offline_default_on": "メールはこのデバイスに保存されるので、オフラインでも読めます。"
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "메일에서 “{text}” 검색"
},
"mail_p2_desk": {
"reader_offline": "오프라인 상태입니다. 이 메시지를 열려면 연결이 필요합니다.",
"reader_failed": "이 메시지를 불러오지 못했습니다.",
"retry": "다시 시도",
"action_offline": "오프라인 상태입니다. 이 작업에는 연결이 필요합니다.",
"offline_bar": "오프라인 상태입니다. 메일을 읽고 변경하려면 연결이 필요합니다.",
"offline_default_on": "메일이 이 기기에 보관되어 오프라인에서도 읽을 수 있습니다."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Meklēt pastā “{text}”"
},
"mail_p2_desk": {
"reader_offline": "Jūs esat bezsaistē. Šīs vēstules atvēršanai vajag savienojumu.",
"reader_failed": "Šo vēstuli neizdevās ielādēt.",
"retry": "Mēģināt vēlreiz",
"action_offline": "Jūs esat bezsaistē. Tam vajag savienojumu.",
"offline_bar": "Jūs esat bezsaistē. Pasta lasīšanai un mainīšanai vajag savienojumu.",
"offline_default_on": "Jūsu pasts tiek glabāts šajā ierīcē, tāpēc to var lasīt bezsaistē."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Захидлаас “{text}”-г хайх"
},
"mail_p2_desk": {
"reader_offline": "Та офлайн байна. Энэ захиаг нээхэд холболт хэрэгтэй.",
"reader_failed": "Энэ захиаг ачаалж чадсангүй.",
"retry": "Дахин оролдох",
"action_offline": "Та офлайн байна. Үүнд холболт хэрэгтэй.",
"offline_bar": "Та офлайн байна. Захиа унших, өөрчлөхөд холболт хэрэгтэй.",
"offline_default_on": "Таны захиа энэ төхөөрөмж дээр хадгалагддаг тул офлайн үед ч уншиж болно."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Søk etter «{text}» i e-post"
},
"mail_p2_desk": {
"reader_offline": "Du er frakoblet. Denne meldingen trenger tilkobling for å åpnes.",
"reader_failed": "Meldingen kunne ikke lastes inn.",
"retry": "Prøv igjen",
"action_offline": "Du er frakoblet. Dette trenger tilkobling.",
"offline_bar": "Du er frakoblet. Å lese og endre e-post trenger tilkobling.",
"offline_default_on": "E-posten din lagres på denne enheten, så du kan lese den uten nett."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Zoek ‘{text}’ in e-mail"
},
"mail_p2_desk": {
"reader_offline": "Je bent offline. Dit bericht heeft een verbinding nodig om te openen.",
"reader_failed": "Dit bericht kon niet worden geladen.",
"retry": "Opnieuw proberen",
"action_offline": "Je bent offline. Hiervoor is een verbinding nodig.",
"offline_bar": "Je bent offline. Voor het lezen en wijzigen van mail is een verbinding nodig.",
"offline_default_on": "Je mail wordt op dit apparaat bewaard, zodat je hem offline kunt lezen."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Szukaj „{text}” w poczcie"
},
"mail_p2_desk": {
"reader_offline": "Jesteś offline. Ta wiadomość otworzy się tylko z połączeniem.",
"reader_failed": "Nie udało się wczytać tej wiadomości.",
"retry": "Spróbuj ponownie",
"action_offline": "Jesteś offline. To wymaga połączenia.",
"offline_bar": "Jesteś offline. Czytanie i zmienianie poczty wymaga połączenia.",
"offline_default_on": "Twoja poczta jest przechowywana na tym urządzeniu, więc możesz ją czytać offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Pesquisar «{text}» no correio"
},
"mail_p2_desk": {
"reader_offline": "Está offline. Esta mensagem precisa de ligação para abrir.",
"reader_failed": "Não foi possível carregar esta mensagem.",
"retry": "Tentar novamente",
"action_offline": "Está offline. Isto precisa de ligação.",
"offline_bar": "Está offline. Ler e alterar e-mails precisa de ligação.",
"offline_default_on": "Os seus e-mails ficam guardados neste dispositivo, para os poder ler offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Căutare „{text}” în e-mail"
},
"mail_p2_desk": {
"reader_offline": "Sunteți offline. Acest mesaj are nevoie de conexiune ca să se deschidă.",
"reader_failed": "Mesajul nu a putut fi încărcat.",
"retry": "Încercați din nou",
"action_offline": "Sunteți offline. Pentru asta e nevoie de conexiune.",
"offline_bar": "Sunteți offline. Citirea și modificarea e-mailurilor au nevoie de conexiune.",
"offline_default_on": "E-mailurile dvs. sunt păstrate pe acest dispozitiv, ca să le puteți citi offline."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Найти «{text}» в почте"
},
"mail_p2_desk": {
"reader_offline": "Нет подключения. Чтобы открыть это письмо, нужно подключение.",
"reader_failed": "Не удалось загрузить это письмо.",
"retry": "Повторить",
"action_offline": "Нет подключения. Для этого нужно подключение.",
"offline_bar": "Нет подключения. Чтобы читать и изменять почту, нужно подключение.",
"offline_default_on": "Ваша почта хранится на этом устройстве, поэтому её можно читать без сети."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Hľadať „{text}“ v pošte"
},
"mail_p2_desk": {
"reader_offline": "Ste offline. Táto správa sa otvorí len s pripojením.",
"reader_failed": "Túto správu sa nepodarilo načítať.",
"retry": "Skúsiť znova",
"action_offline": "Ste offline. Na to je potrebné pripojenie.",
"offline_bar": "Ste offline. Na čítanie a úpravy pošty je potrebné pripojenie.",
"offline_default_on": "Vaša pošta je uložená v tomto zariadení, takže ju môžete čítať offline."
},
"desktopMenuBar": {
+5
View File
@@ -7057,6 +7057,11 @@
"context_search_mail": "ค้นหา “{text}” ในอีเมล"
},
"mail_p2_desk": {
"reader_offline": "คุณออฟไลน์อยู่ ข้อความนี้ต้องมีการเชื่อมต่อจึงจะเปิดได้",
"reader_failed": "โหลดข้อความนี้ไม่ได้",
"retry": "ลองอีกครั้ง",
"action_offline": "คุณออฟไลน์อยู่ การทำงานนี้ต้องมีการเชื่อมต่อ",
"offline_bar": "คุณออฟไลน์อยู่ การอ่านและแก้ไขอีเมลต้องมีการเชื่อมต่อ",
"offline_default_on": "อีเมลของคุณถูกเก็บไว้ในอุปกรณ์นี้ คุณจึงอ่านได้แม้ออฟไลน์"
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "E-postalarda “{text}” ara"
},
"mail_p2_desk": {
"reader_offline": "Çevrimdışısınız. Bu iletiyi açmak için bağlantı gerekiyor.",
"reader_failed": "Bu ileti yüklenemedi.",
"retry": "Tekrar dene",
"action_offline": "Çevrimdışısınız. Bunun için bağlantı gerekiyor.",
"offline_bar": "Çevrimdışısınız. E-postaları okumak ve değiştirmek için bağlantı gerekiyor.",
"offline_default_on": "E-postalarınız bu cihazda saklanır, böylece çevrimdışıyken de okuyabilirsiniz."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "Шукати «{text}» у пошті"
},
"mail_p2_desk": {
"reader_offline": "Немає з'єднання. Щоб відкрити цей лист, потрібне з'єднання.",
"reader_failed": "Не вдалося завантажити цей лист.",
"retry": "Спробувати ще",
"action_offline": "Немає з'єднання. Для цього потрібне з'єднання.",
"offline_bar": "Немає з'єднання. Щоб читати й змінювати пошту, потрібне з'єднання.",
"offline_default_on": "Ваша пошта зберігається на цьому пристрої, тож її можна читати без мережі."
},
"desktopMenuBar": {
+5
View File
@@ -7061,6 +7061,11 @@
"context_search_mail": "Tìm “{text}” trong thư"
},
"mail_p2_desk": {
"reader_offline": "Bạn đang ngoại tuyến. Cần có kết nối để mở thư này.",
"reader_failed": "Không tải được thư này.",
"retry": "Thử lại",
"action_offline": "Bạn đang ngoại tuyến. Việc này cần có kết nối.",
"offline_bar": "Bạn đang ngoại tuyến. Cần có kết nối để đọc và thay đổi thư.",
"offline_default_on": "Thư của bạn được lưu trên thiết bị này, nên bạn có thể đọc khi ngoại tuyến."
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "在郵件中搜尋「{text}」"
},
"mail_p2_desk": {
"reader_offline": "您已離線。開啟這封郵件需要網路連線。",
"reader_failed": "無法載入這封郵件。",
"retry": "重試",
"action_offline": "您已離線。此操作需要網路連線。",
"offline_bar": "您已離線。閱讀和變更郵件需要網路連線。",
"offline_default_on": "您的郵件保存在此裝置上,離線時也能閱讀。"
},
"desktopMenuBar": {
+5
View File
@@ -7049,6 +7049,11 @@
"context_search_mail": "在邮件中搜索“{text}”"
},
"mail_p2_desk": {
"reader_offline": "您已离线。打开这封邮件需要网络连接。",
"reader_failed": "无法加载这封邮件。",
"retry": "重试",
"action_offline": "您已离线。此操作需要网络连接。",
"offline_bar": "您已离线。阅读和更改邮件需要网络连接。",
"offline_default_on": "您的邮件保存在此设备上,离线时也能阅读。"
},
"desktopMenuBar": {