diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 8d5589501..9164a715f 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -26,6 +26,7 @@ import { PluginAppEvents } from "@/components/providers/plugin-app-events"; import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; +import { OfflineHost } from "@/components/offline/offline-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -100,6 +101,8 @@ export default async function LocaleLayout({ + {/* Offline mode isn't in Lite (no service worker there). */} + {!IS_LITE && } {/* Lite is a static bundle: no server keeps an archive there. */} diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 5cee22b98..e144984b8 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -183,6 +183,7 @@ function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); const tSessionLimits = useTranslations("session_limits"); + const tOffline = useTranslations("offline"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); @@ -1418,6 +1419,11 @@ function LoginPageContent() { {(sessionEnd?.draftsUnsaved || expiredDraftsUnsaved) && ( {tSessionLimits("drafts_not_saved")} )} + {sessionEnd?.outboxLost?.length ? ( + + {tOffline("signout.lost", { subjects: sessionEnd.outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} + + ) : null}

+ )} + + ); +} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx new file mode 100644 index 000000000..85cc83289 --- /dev/null +++ b/components/offline/offline-host.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { addSignOutGate } from '@/lib/sign-out-cleanup'; +import { reloadToNewBuild } from '@/lib/app-update'; +import { setOfflineNotifier, startOfflineMode, type OfflineNotice } from '@/lib/offline/offline-mode'; +import { useOfflineStore } from '@/lib/offline/state'; +import { toast } from '@/stores/toast-store'; + +/** + * Runs offline mode (#523) in this tab: the JMAP client's offline hooks, + * the outbox, the wipes. Its notices become toasts, and a sign-out the + * user asked for while messages wait in the outbox asks first. Not mounted + * in Lite (no offline mode there). + */ +export function OfflineHost() { + const t = useTranslations('offline'); + const tRef = useRef(t); + tRef.current = t; + const { dialogProps, confirm } = useConfirmDialog(); + const confirmRef = useRef(confirm); + confirmRef.current = confirm; + + useEffect(() => { + const show = (notice: OfflineNotice) => { + const tr = tRef.current; + switch (notice.kind) { + case 'queued': + toast.info(tr('toast.queued')); + break; + case 'sent': + toast.success(tr('toast.sent', { subject: notice.subject || tr('outbox.no_subject') })); + break; + case 'send-failed': + toast.error(tr('toast.send_failed', { subject: notice.subject || tr('outbox.no_subject') }), { message: notice.error }); + break; + case 'refused-delete': + toast.warning(tr('toast.refused_delete')); + break; + case 'refused-other': + toast.warning(tr('toast.refused_other')); + break; + case 'forbidden': + toast.warning(tr('toast.forbidden')); + break; + case 'back-online-reload': + toast.info(tr('toast.back_online'), { + duration: 0, + action: { label: tr('toast.reload'), onClick: () => { void reloadToNewBuild(); } }, + }); + break; + } + }; + setOfflineNotifier(show); + const stop = startOfflineMode(); + // Messages that would be lost: ask before a sign-out the user started. + const removeGate = addSignOutGate(async (accountId) => { + const unsent = useOfflineStore.getState().outbox.filter((row) => accountId === 'all' || row.account === accountId).length; + if (unsent === 0) return true; + const tr = tRef.current; + return confirmRef.current({ + title: tr('signout.title'), + message: tr('signout.message', { count: unsent }), + confirmText: tr('signout.confirm'), + variant: 'destructive', + }); + }); + return () => { + removeGate(); + stop(); + setOfflineNotifier(() => {}); + }; + }, []); + + return ; +} diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx new file mode 100644 index 000000000..013e37ecb --- /dev/null +++ b/components/offline/offline-settings.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { AlertTriangle } from '@/components/icons'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { OrgLockedNote } from '@/components/settings/org-locked-note'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { IS_LITE } from '@/lib/lite'; +import { formatFileSize } from '@/lib/utils'; +import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode'; +import { useOfflineStore } from '@/lib/offline/state'; +import { useDevicePolicy } from '@/hooks/use-device-policy'; +import { useAuthStore } from '@/stores/auth-store'; +import { OutboxList } from './outbox-list'; + +/** + * Settings > Backup & reset > Offline (#523): "Keep mail on this device" + * for the active account, the space it uses, and the outbox. Not in Lite. + * When the organisation forbids it, the switch is gone and a note says so. + */ +export function OfflineSettings() { + const t = useTranslations('offline.settings'); + const format = useFormatter(); + const account = useAuthStore((s) => s.activeAccountId); + // Re-renders when the organisation's device settings change. + const devices = useDevicePolicy(); + const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account)); + const usage = useOfflineStore((s) => s.usageBytes); + const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account)); + const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined)); + const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); + const [confirmOff, setConfirmOff] = useState(false); + + if (IS_LITE) return null; + const allowed = !devices.noOffline && offlineAllowed(account); + + const turnOff = () => { + if (!account) return; + void disableOffline(account); + }; + + return ( + + {!offlineSupported() ? ( +

{t('unsupported')}

+ ) : !allowed ? ( +
+

{t('keep_label')}

+

{t('forbidden')}

+ +
+ ) : ( + <> + + { + if (!account) return; + if (on) void enableOffline(account); + else if (unsent > 0) setConfirmOff(true); + else turnOff(); + }} + /> + +
+
+ {enabled && ( +

+ {syncing + ? t('syncing') + : lastSynced + ? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) }) + : t('usage', { size: formatFileSize(usage) })} +

+ )} + + )} + + setConfirmOff(false)} + onConfirm={() => { setConfirmOff(false); turnOff(); }} + title={t('off_confirm_title')} + message={t('off_confirm_message', { count: unsent })} + confirmText={t('off_confirm')} + variant="destructive" + /> +
+ ); +} diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx new file mode 100644 index 000000000..2fdc22f42 --- /dev/null +++ b/components/offline/outbox-list.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useState } from 'react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush'; +import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode'; +import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state'; +import { useAccountStore } from '@/stores/account-store'; + +/** + * The offline outbox (#523): messages written offline that wait for the + * network, with what happened to them. A message that couldn't be sent can + * be sent again or deleted; one whose connection broke on the way is never + * sent again on its own (it may have gone out). + */ +export function OutboxList() { + const t = useTranslations('offline.outbox'); + const format = useFormatter(); + const rows = useOfflineStore((s) => s.outbox); + const changes = useOfflineStore((s) => s.pendingChanges); + const accounts = useAccountStore((s) => s.accounts); + const [deleting, setDeleting] = useState(null); + const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1; + + const status = (row: OutboxRow) => { + switch (row.status) { + case 'sending': return t('status_sending'); + case 'failed': return t('status_failed', { error: row.error ?? '' }); + case 'uncertain': return t('status_uncertain'); + default: return t('status_waiting'); + } + }; + + const retry = async (row: OutboxRow) => { + await retryOutboxEntry(row.id); + await refreshQueueState(); + if (!isOfflineNow()) scheduleFlushAll(0); + }; + + return ( +
+

{t('title')}

+

{t('description')}

+ {rows.length === 0 ? ( +

{t('empty')}

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    +

    {row.subject || t('no_subject')}

    +

    + {t('to', { recipients: row.to.join(', ') })} + {several && <> · {accounts.find((a) => a.id === row.account)?.label ?? row.account}} +

    +

    + {status(row)} · {format.dateTime(row.createdAt, { dateStyle: 'short', timeStyle: 'short' })} +

    +
    +
    + {(row.status === 'failed' || row.status === 'uncertain') && ( + + )} + {row.status !== 'sending' && ( + + )} +
    +
  • + ))} +
+ )} + {changes > 0 && ( +

{t('changes', { count: changes })}

+ )} + setDeleting(null)} + onConfirm={() => { + const row = deleting; + setDeleting(null); + if (row) void deleteOutboxEntry(row.id).then(refreshQueueState); + }} + title={t('delete_confirm_title')} + message={t('delete_confirm_message')} + confirmText={t('delete')} + variant="destructive" + /> +
+ ); +} diff --git a/components/plugins/app-top-banner-slot.tsx b/components/plugins/app-top-banner-slot.tsx index 0115007a8..498ba4705 100644 --- a/components/plugins/app-top-banner-slot.tsx +++ b/components/plugins/app-top-banner-slot.tsx @@ -2,6 +2,7 @@ import { PluginSlot } from '@/components/plugins/plugin-slot'; import { OrgAnnouncement } from '@/components/org/org-announcement'; +import { OfflineBanner } from '@/components/offline/offline-banner'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -25,6 +26,7 @@ export function AppTopBannerSlot() { return ( <> {!embedded && } + {!embedded && } ); diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 17db44064..e7f71d24d 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -17,6 +17,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { toast } from '@/stores/toast-store'; import { DebugSettings } from './debug-settings'; import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings'; +import { OfflineSettings } from '@/components/offline/offline-settings'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -217,6 +218,8 @@ export function AboutDataSettings() { + +