mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: say when a message needs a connection in a browser that keeps no mail offline
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "البحث في البريد عن «{text}»"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "أنت غير متصل. تحتاج هذه الرسالة إلى اتصال لفتحها.",
|
||||
"reader_failed": "تعذّر تحميل هذه الرسالة.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"action_offline": "أنت غير متصل. يحتاج هذا إلى اتصال.",
|
||||
"offline_bar": "أنت غير متصل. قراءة البريد وتغييره يحتاجان إلى اتصال.",
|
||||
"offline_default_on": "يُحفظ بريدك على هذا الجهاز، لذا يمكنك قراءته دون اتصال."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "جستجوی «{text}» در نامهها"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "آفلاین هستید. برای باز کردن این پیام اتصال لازم است.",
|
||||
"reader_failed": "این پیام بارگیری نشد.",
|
||||
"retry": "تلاش دوباره",
|
||||
"action_offline": "آفلاین هستید. برای این کار اتصال لازم است.",
|
||||
"offline_bar": "آفلاین هستید. برای خواندن و تغییر ایمیل اتصال لازم است.",
|
||||
"offline_default_on": "ایمیلهای شما روی این دستگاه نگه داشته میشود تا آفلاین هم بخوانید."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7050,6 +7050,11 @@
|
||||
"context_search_mail": "חיפוש “{text}” בדואר"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "אין חיבור. כדי לפתוח הודעה זו צריך חיבור.",
|
||||
"reader_failed": "לא ניתן היה לטעון הודעה זו.",
|
||||
"retry": "לנסות שוב",
|
||||
"action_offline": "אין חיבור. לפעולה זו צריך חיבור.",
|
||||
"offline_bar": "אין חיבור. כדי לקרוא ולשנות דואר צריך חיבור.",
|
||||
"offline_default_on": "הדואר שלך נשמר במכשיר הזה, כך שאפשר לקרוא אותו גם בלי חיבור."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "メールで「{text}」を検索"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "オフラインです。このメッセージを開くには接続が必要です。",
|
||||
"reader_failed": "このメッセージを読み込めませんでした。",
|
||||
"retry": "再試行",
|
||||
"action_offline": "オフラインです。この操作には接続が必要です。",
|
||||
"offline_bar": "オフラインです。メールの閲覧や変更には接続が必要です。",
|
||||
"offline_default_on": "メールはこのデバイスに保存されるので、オフラインでも読めます。"
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "메일에서 “{text}” 검색"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "오프라인 상태입니다. 이 메시지를 열려면 연결이 필요합니다.",
|
||||
"reader_failed": "이 메시지를 불러오지 못했습니다.",
|
||||
"retry": "다시 시도",
|
||||
"action_offline": "오프라인 상태입니다. 이 작업에는 연결이 필요합니다.",
|
||||
"offline_bar": "오프라인 상태입니다. 메일을 읽고 변경하려면 연결이 필요합니다.",
|
||||
"offline_default_on": "메일이 이 기기에 보관되어 오프라인에서도 읽을 수 있습니다."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "Захидлаас “{text}”-г хайх"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "Та офлайн байна. Энэ захиаг нээхэд холболт хэрэгтэй.",
|
||||
"reader_failed": "Энэ захиаг ачаалж чадсангүй.",
|
||||
"retry": "Дахин оролдох",
|
||||
"action_offline": "Та офлайн байна. Үүнд холболт хэрэгтэй.",
|
||||
"offline_bar": "Та офлайн байна. Захиа унших, өөрчлөхөд холболт хэрэгтэй.",
|
||||
"offline_default_on": "Таны захиа энэ төхөөрөмж дээр хадгалагддаг тул офлайн үед ч уншиж болно."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "Найти «{text}» в почте"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "Нет подключения. Чтобы открыть это письмо, нужно подключение.",
|
||||
"reader_failed": "Не удалось загрузить это письмо.",
|
||||
"retry": "Повторить",
|
||||
"action_offline": "Нет подключения. Для этого нужно подключение.",
|
||||
"offline_bar": "Нет подключения. Чтобы читать и изменять почту, нужно подключение.",
|
||||
"offline_default_on": "Ваша почта хранится на этом устройстве, поэтому её можно читать без сети."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7057,6 +7057,11 @@
|
||||
"context_search_mail": "ค้นหา “{text}” ในอีเมล"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "คุณออฟไลน์อยู่ ข้อความนี้ต้องมีการเชื่อมต่อจึงจะเปิดได้",
|
||||
"reader_failed": "โหลดข้อความนี้ไม่ได้",
|
||||
"retry": "ลองอีกครั้ง",
|
||||
"action_offline": "คุณออฟไลน์อยู่ การทำงานนี้ต้องมีการเชื่อมต่อ",
|
||||
"offline_bar": "คุณออฟไลน์อยู่ การอ่านและแก้ไขอีเมลต้องมีการเชื่อมต่อ",
|
||||
"offline_default_on": "อีเมลของคุณถูกเก็บไว้ในอุปกรณ์นี้ คุณจึงอ่านได้แม้ออฟไลน์"
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "Шукати «{text}» у пошті"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "Немає з'єднання. Щоб відкрити цей лист, потрібне з'єднання.",
|
||||
"reader_failed": "Не вдалося завантажити цей лист.",
|
||||
"retry": "Спробувати ще",
|
||||
"action_offline": "Немає з'єднання. Для цього потрібне з'єднання.",
|
||||
"offline_bar": "Немає з'єднання. Щоб читати й змінювати пошту, потрібне з'єднання.",
|
||||
"offline_default_on": "Ваша пошта зберігається на цьому пристрої, тож її можна читати без мережі."
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "在郵件中搜尋「{text}」"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "您已離線。開啟這封郵件需要網路連線。",
|
||||
"reader_failed": "無法載入這封郵件。",
|
||||
"retry": "重試",
|
||||
"action_offline": "您已離線。此操作需要網路連線。",
|
||||
"offline_bar": "您已離線。閱讀和變更郵件需要網路連線。",
|
||||
"offline_default_on": "您的郵件保存在此裝置上,離線時也能閱讀。"
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
@@ -7049,6 +7049,11 @@
|
||||
"context_search_mail": "在邮件中搜索“{text}”"
|
||||
},
|
||||
"mail_p2_desk": {
|
||||
"reader_offline": "您已离线。打开这封邮件需要网络连接。",
|
||||
"reader_failed": "无法加载这封邮件。",
|
||||
"retry": "重试",
|
||||
"action_offline": "您已离线。此操作需要网络连接。",
|
||||
"offline_bar": "您已离线。阅读和更改邮件需要网络连接。",
|
||||
"offline_default_on": "您的邮件保存在此设备上,离线时也能阅读。"
|
||||
},
|
||||
"desktopMenuBar": {
|
||||
|
||||
Reference in New Issue
Block a user