diff --git a/components/email/reader-open-failure.tsx b/components/email/reader-open-failure.tsx new file mode 100644 index 000000000..598ab25fd --- /dev/null +++ b/components/email/reader-open-failure.tsx @@ -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 ( +
+
+ ); +} diff --git a/components/mail/__tests__/mail-app-open-offline.test.tsx b/components/mail/__tests__/mail-app-open-offline.test.tsx new file mode 100644 index 000000000..34699ce66 --- /dev/null +++ b/components/mail/__tests__/mail-app-open-offline.test.tsx @@ -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>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ?
reader
:
{props.emptyPaneContent ?? 'empty pane'}
), +})); +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: () =>
message list
})); + +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; +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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..7da957b92 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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>; offline: boolean } | null>(null); + const tP2Desk = useTranslations('mail_p2_desk'); // The message the palette's "Move to…" was picked for. const [moveDialogEmail, setMoveDialogEmail] = useState(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 => { 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 ?
- : undefined} + : openFailure && !isLoadingEmail + ? void handleEmailSelect(openFailure.target)} /> + : undefined} emptyPaneFooter={emptyPaneBesideList ? : undefined} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split("@")[0]} diff --git a/components/offline/__tests__/offline-banner-no-store.test.tsx b/components/offline/__tests__/offline-banner-no-store.test.tsx new file mode 100644 index 000000000..ea6f40aa3 --- /dev/null +++ b/components/offline/__tests__/offline-banner-no-store.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.queryByTestId('offline-banner-no-store')).toBeNull(); + }); +}); diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx index 4efe20b44..d3a678980 100644 --- a/components/offline/offline-banner.tsx +++ b/components/offline/offline-banner.tsx @@ -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(hiddenFor); const [banner, setBanner] = useState(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 ( +
+
+ ); + } 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 = () => { diff --git a/lib/__tests__/offline-key-guard.test.ts b/lib/__tests__/offline-key-guard.test.ts new file mode 100644 index 000000000..46319a5a0 --- /dev/null +++ b/lib/__tests__/offline-key-guard.test.ts @@ -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(); + }); +}); diff --git a/lib/offline-key-guard.ts b/lib/offline-key-guard.ts new file mode 100644 index 000000000..349318fad --- /dev/null +++ b/lib/offline-key-guard.ts @@ -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(handlers: T, canChangeMail: () => boolean, onRefused: () => void): T { + const guarded = { ...handlers } as Record; + for (const key of MAIL_CHANGE_SHORTCUTS) { + const handler = (handlers as Record)[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; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 9df4371b6..1e8a162d3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "البحث في البريد عن «{text}»" }, "mail_p2_desk": { + "reader_offline": "أنت غير متصل. تحتاج هذه الرسالة إلى اتصال لفتحها.", + "reader_failed": "تعذّر تحميل هذه الرسالة.", + "retry": "إعادة المحاولة", + "action_offline": "أنت غير متصل. يحتاج هذا إلى اتصال.", + "offline_bar": "أنت غير متصل. قراءة البريد وتغييره يحتاجان إلى اتصال.", "offline_default_on": "يُحفظ بريدك على هذا الجهاز، لذا يمكنك قراءته دون اتصال." }, "desktopMenuBar": { diff --git a/locales/ca/common.json b/locales/ca/common.json index eba62dfa2..4d6cb97fa 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 7739d6271..e39106788 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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": { diff --git a/locales/da/common.json b/locales/da/common.json index abc9910fb..98e387fea 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": { diff --git a/locales/de/common.json b/locales/de/common.json index db3b71831..88be04c5c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": { diff --git a/locales/en/common.json b/locales/en/common.json index 677699b97..e3cc8a519 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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": { diff --git a/locales/es/common.json b/locales/es/common.json index 4a2f12207..072f6cf61 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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": { diff --git a/locales/et/common.json b/locales/et/common.json index ec50dc87b..f7353450e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 7c74c1919..93f48d9bb 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "جستجوی «{text}» در نامه‌ها" }, "mail_p2_desk": { + "reader_offline": "آفلاین هستید. برای باز کردن این پیام اتصال لازم است.", + "reader_failed": "این پیام بارگیری نشد.", + "retry": "تلاش دوباره", + "action_offline": "آفلاین هستید. برای این کار اتصال لازم است.", + "offline_bar": "آفلاین هستید. برای خواندن و تغییر ایمیل اتصال لازم است.", "offline_default_on": "ایمیل‌های شما روی این دستگاه نگه داشته می‌شود تا آفلاین هم بخوانید." }, "desktopMenuBar": { diff --git a/locales/fr/common.json b/locales/fr/common.json index f5835e287..5f5825eef 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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": { diff --git a/locales/he/common.json b/locales/he/common.json index 60d487925..a664c7f52 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7050,6 +7050,11 @@ "context_search_mail": "חיפוש “{text}” בדואר" }, "mail_p2_desk": { + "reader_offline": "אין חיבור. כדי לפתוח הודעה זו צריך חיבור.", + "reader_failed": "לא ניתן היה לטעון הודעה זו.", + "retry": "לנסות שוב", + "action_offline": "אין חיבור. לפעולה זו צריך חיבור.", + "offline_bar": "אין חיבור. כדי לקרוא ולשנות דואר צריך חיבור.", "offline_default_on": "הדואר שלך נשמר במכשיר הזה, כך שאפשר לקרוא אותו גם בלי חיבור." }, "desktopMenuBar": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e0d9fb5e9..88d67e586 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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": { diff --git a/locales/it/common.json b/locales/it/common.json index 8aa048aca..ad77537bc 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 464d35f8c..e8ecbb45a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "メールで「{text}」を検索" }, "mail_p2_desk": { + "reader_offline": "オフラインです。このメッセージを開くには接続が必要です。", + "reader_failed": "このメッセージを読み込めませんでした。", + "retry": "再試行", + "action_offline": "オフラインです。この操作には接続が必要です。", + "offline_bar": "オフラインです。メールの閲覧や変更には接続が必要です。", "offline_default_on": "メールはこのデバイスに保存されるので、オフラインでも読めます。" }, "desktopMenuBar": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 80256a370..db2801393 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "메일에서 “{text}” 검색" }, "mail_p2_desk": { + "reader_offline": "오프라인 상태입니다. 이 메시지를 열려면 연결이 필요합니다.", + "reader_failed": "이 메시지를 불러오지 못했습니다.", + "retry": "다시 시도", + "action_offline": "오프라인 상태입니다. 이 작업에는 연결이 필요합니다.", + "offline_bar": "오프라인 상태입니다. 메일을 읽고 변경하려면 연결이 필요합니다.", "offline_default_on": "메일이 이 기기에 보관되어 오프라인에서도 읽을 수 있습니다." }, "desktopMenuBar": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 1e420eb9b..1633baac8 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 13f807146..974c0a29a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Захидлаас “{text}”-г хайх" }, "mail_p2_desk": { + "reader_offline": "Та офлайн байна. Энэ захиаг нээхэд холболт хэрэгтэй.", + "reader_failed": "Энэ захиаг ачаалж чадсангүй.", + "retry": "Дахин оролдох", + "action_offline": "Та офлайн байна. Үүнд холболт хэрэгтэй.", + "offline_bar": "Та офлайн байна. Захиа унших, өөрчлөхөд холболт хэрэгтэй.", "offline_default_on": "Таны захиа энэ төхөөрөмж дээр хадгалагддаг тул офлайн үед ч уншиж болно." }, "desktopMenuBar": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 6d9fbb86f..f2c081647 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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": { diff --git a/locales/nl/common.json b/locales/nl/common.json index e54b2b5d8..2f2d8a963 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 2c94ef990..464e3ca04 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 47f68e1fe..a163230fb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 072f91750..389b72cbe 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 1a63e300c..9ff16ffb5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Найти «{text}» в почте" }, "mail_p2_desk": { + "reader_offline": "Нет подключения. Чтобы открыть это письмо, нужно подключение.", + "reader_failed": "Не удалось загрузить это письмо.", + "retry": "Повторить", + "action_offline": "Нет подключения. Для этого нужно подключение.", + "offline_bar": "Нет подключения. Чтобы читать и изменять почту, нужно подключение.", "offline_default_on": "Ваша почта хранится на этом устройстве, поэтому её можно читать без сети." }, "desktopMenuBar": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 3b8ae2c34..5dde2cc6a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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": { diff --git a/locales/th/common.json b/locales/th/common.json index 7ab12bd90..8eb81097f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7057,6 +7057,11 @@ "context_search_mail": "ค้นหา “{text}” ในอีเมล" }, "mail_p2_desk": { + "reader_offline": "คุณออฟไลน์อยู่ ข้อความนี้ต้องมีการเชื่อมต่อจึงจะเปิดได้", + "reader_failed": "โหลดข้อความนี้ไม่ได้", + "retry": "ลองอีกครั้ง", + "action_offline": "คุณออฟไลน์อยู่ การทำงานนี้ต้องมีการเชื่อมต่อ", + "offline_bar": "คุณออฟไลน์อยู่ การอ่านและแก้ไขอีเมลต้องมีการเชื่อมต่อ", "offline_default_on": "อีเมลของคุณถูกเก็บไว้ในอุปกรณ์นี้ คุณจึงอ่านได้แม้ออฟไลน์" }, "desktopMenuBar": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 18846c6c8..aed707fcf 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 7909002a5..4a9caf71c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Шукати «{text}» у пошті" }, "mail_p2_desk": { + "reader_offline": "Немає з'єднання. Щоб відкрити цей лист, потрібне з'єднання.", + "reader_failed": "Не вдалося завантажити цей лист.", + "retry": "Спробувати ще", + "action_offline": "Немає з'єднання. Для цього потрібне з'єднання.", + "offline_bar": "Немає з'єднання. Щоб читати й змінювати пошту, потрібне з'єднання.", "offline_default_on": "Ваша пошта зберігається на цьому пристрої, тож її можна читати без мережі." }, "desktopMenuBar": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 8bf7c1d0d..5af2773f8 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index de476cf02..4485af7f4 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "在郵件中搜尋「{text}」" }, "mail_p2_desk": { + "reader_offline": "您已離線。開啟這封郵件需要網路連線。", + "reader_failed": "無法載入這封郵件。", + "retry": "重試", + "action_offline": "您已離線。此操作需要網路連線。", + "offline_bar": "您已離線。閱讀和變更郵件需要網路連線。", "offline_default_on": "您的郵件保存在此裝置上,離線時也能閱讀。" }, "desktopMenuBar": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 3a5997857..c1405cb80 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "在邮件中搜索“{text}”" }, "mail_p2_desk": { + "reader_offline": "您已离线。打开这封邮件需要网络连接。", + "reader_failed": "无法加载这封邮件。", + "retry": "重试", + "action_offline": "您已离线。此操作需要网络连接。", + "offline_bar": "您已离线。阅读和更改邮件需要网络连接。", "offline_default_on": "您的邮件保存在此设备上,离线时也能阅读。" }, "desktopMenuBar": {