Offline compose: write mail offline, Scheduled folder, undo countdown fix
Compose, Reply and Forward work offline. Drafts are kept on the device (IndexedDB, lib/scheduled.ts), with files attached offline; Send becomes "Schedule" and puts the message in a new Scheduled folder. When the server is reachable again, scheduled messages are sent through the normal outbox (Undo offered for a single one) and offline drafts become server drafts — files uploaded first, each step recorded so nothing is created or sent twice, under a Web Lock so two tabs never send the same message. Drafts written offline show in Drafts as "On this device". Sign-out sends what is scheduled, then wipes the device's copy. Also: offline failures from keyboard shortcuts show "Not available offline"; no recipient suggestions are asked for offline; a reply no longer quotes an error page when the original is not saved. The undo countdown counted to the server's deadline on the browser's clock, so a few seconds of skew showed (18 s instead of 10). /send now also returns undo_seconds, and the composer counts down from that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||||
@@ -300,7 +300,10 @@ async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendRespo
|
|||||||
str(outbox.id),
|
str(outbox.id),
|
||||||
_defer_by=timedelta(seconds=get_settings().undo_seconds),
|
_defer_by=timedelta(seconds=get_settings().undo_seconds),
|
||||||
)
|
)
|
||||||
return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat())
|
left = max(0.0, (outbox.send_after - datetime.now(UTC)).total_seconds())
|
||||||
|
return SendResponse(
|
||||||
|
outbox_id=outbox.id, undo_until=outbox.send_after.isoformat(), undo_seconds=left
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send/{outbox_id}/undo")
|
@router.post("/send/{outbox_id}/undo")
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class SendRequest(BaseModel):
|
|||||||
class SendResponse(BaseModel):
|
class SendResponse(BaseModel):
|
||||||
outbox_id: uuid.UUID
|
outbox_id: uuid.UUID
|
||||||
undo_until: str
|
undo_until: str
|
||||||
|
# The same deadline as seconds from now: the browser counts down from this,
|
||||||
|
# since its clock may be seconds off the server's.
|
||||||
|
undo_seconds: float
|
||||||
|
|
||||||
|
|
||||||
class OutboxStateOut(BaseModel):
|
class OutboxStateOut(BaseModel):
|
||||||
|
|||||||
@@ -398,6 +398,8 @@ async def test_send_creates_outbox_and_defers(ctx: Ctx) -> None:
|
|||||||
resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())
|
resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
outbox_id = resp.json()["outbox_id"]
|
outbox_id = resp.json()["outbox_id"]
|
||||||
|
# The window as a duration too, so the browser's clock does not matter.
|
||||||
|
assert 0 < resp.json()["undo_seconds"] <= get_settings().undo_seconds
|
||||||
|
|
||||||
ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
|
ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id))
|
||||||
assert ob is not None and ob.state == "undo_window"
|
assert ob is not None and ob.state == "undo_window"
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
// - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an
|
// - OfflineIndicator (the sidebar header): the amber "Offline" cloud, with an
|
||||||
// explanation on click, and a short green "Back online" when it returns.
|
// explanation on click, and a short green "Back online" when it returns.
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
|
import { isOfflineError } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
isOffline,
|
isOffline,
|
||||||
offlineEnabled,
|
offlineEnabled,
|
||||||
@@ -19,40 +20,22 @@ import {
|
|||||||
useOffline,
|
useOffline,
|
||||||
useOfflineEnabled,
|
useOfflineEnabled,
|
||||||
} from "../lib/offline";
|
} from "../lib/offline";
|
||||||
|
import {
|
||||||
|
clearOfflineNotice,
|
||||||
|
setBackOnline,
|
||||||
|
showOfflineNotice,
|
||||||
|
useBackOnline,
|
||||||
|
useOfflineNotice,
|
||||||
|
} from "../lib/offlineNotice";
|
||||||
import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch";
|
import { prefetchRecent, serverAnswers } from "../lib/offlinePrefetch";
|
||||||
|
import { flushLocal, type ScheduledSent } from "../lib/scheduled";
|
||||||
import { subscribeWs } from "../lib/ws";
|
import { subscribeWs } from "../lib/ws";
|
||||||
|
import { hasUndoHost, offerUndo } from "./compose/undoSend";
|
||||||
|
|
||||||
const PROBE_MS = 10_000;
|
const PROBE_MS = 10_000;
|
||||||
const BACK_ONLINE_MS = 3_000;
|
const BACK_ONLINE_MS = 3_000;
|
||||||
const REFRESH_MS = 15 * 60_000;
|
const REFRESH_MS = 15 * 60_000;
|
||||||
|
|
||||||
// ---- the short-lived bits of UI the two share ---------------------------------
|
|
||||||
|
|
||||||
type Notice = { key: "offline" | "offlineNotSaved" | "notAvailable"; seq: number } | null;
|
|
||||||
|
|
||||||
let notice: Notice = null;
|
|
||||||
let backOnline = false;
|
|
||||||
const listeners = new Set<() => void>();
|
|
||||||
const emit = () => listeners.forEach((l) => l());
|
|
||||||
const subscribe = (l: () => void) => {
|
|
||||||
listeners.add(l);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(l);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
let noticeTimer: number | undefined;
|
|
||||||
let noticeSeq = 0;
|
|
||||||
|
|
||||||
function showNotice(key: NonNullable<Notice>["key"], ms: number) {
|
|
||||||
notice = { key, seq: ++noticeSeq };
|
|
||||||
emit();
|
|
||||||
window.clearTimeout(noticeTimer);
|
|
||||||
noticeTimer = window.setTimeout(() => {
|
|
||||||
notice = null;
|
|
||||||
emit();
|
|
||||||
}, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloudOff({ size = 16 }: { size?: number }) {
|
function CloudOff({ size = 16 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
@@ -63,6 +46,15 @@ function CloudOff({ size = 16 }: { size?: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Clock({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M12 7.5V12l3 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Check({ size = 16 }: { size?: number }) {
|
function Check({ size = 16 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
@@ -73,12 +65,33 @@ function Check({ size = 16 }: { size?: number }) {
|
|||||||
|
|
||||||
// ---- the controller ------------------------------------------------------------
|
// ---- the controller ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Hand messages written offline to the server; offer Undo for a single send. */
|
||||||
|
async function sendScheduled(queryClient: QueryClient): Promise<void> {
|
||||||
|
if (isOffline()) return;
|
||||||
|
let sent: ScheduledSent[];
|
||||||
|
try {
|
||||||
|
sent = await flushLocal();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of ["drafts", "drafts-remote", "folder-counts", "threads"]) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
|
}
|
||||||
|
if (!sent.length) return;
|
||||||
|
if (sent.length === 1 && hasUndoHost()) {
|
||||||
|
const [one] = sent;
|
||||||
|
offerUndo({ outboxId: one.outboxId, draftId: one.draftId, until: Date.now() + one.undoSeconds * 1000 });
|
||||||
|
} else {
|
||||||
|
showOfflineNotice("sendingScheduled", 6000, { count: sent.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function OfflineController() {
|
export function OfflineController() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const offline = useOffline();
|
const offline = useOffline();
|
||||||
const enabled = useOfflineEnabled();
|
const enabled = useOfflineEnabled();
|
||||||
const current = useSyncExternalStore(subscribe, () => notice);
|
const current = useOfflineNotice();
|
||||||
const wasOffline = useRef(false);
|
const wasOffline = useRef(false);
|
||||||
|
|
||||||
// Install (or remove) the service worker to match this device's setting.
|
// Install (or remove) the service worker to match this device's setting.
|
||||||
@@ -90,23 +103,35 @@ export function OfflineController() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offline) {
|
if (offline) {
|
||||||
wasOffline.current = true;
|
wasOffline.current = true;
|
||||||
showNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000);
|
showOfflineNotice(offlineEnabled() ? "offline" : "offlineNotSaved", 6000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!wasOffline.current) return;
|
if (!wasOffline.current) return;
|
||||||
wasOffline.current = false;
|
wasOffline.current = false;
|
||||||
notice = null;
|
clearOfflineNotice();
|
||||||
backOnline = true;
|
setBackOnline(true);
|
||||||
emit();
|
const timer = window.setTimeout(() => setBackOnline(false), BACK_ONLINE_MS);
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
backOnline = false;
|
|
||||||
emit();
|
|
||||||
}, BACK_ONLINE_MS);
|
|
||||||
void queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
|
void sendScheduled(queryClient);
|
||||||
void prefetchRecent();
|
void prefetchRecent();
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [offline, queryClient]);
|
}, [offline, queryClient]);
|
||||||
|
|
||||||
|
// Messages written offline: sent (Scheduled) or made server drafts as soon
|
||||||
|
// as the server is there — on start, and checked again every minute.
|
||||||
|
useEffect(() => {
|
||||||
|
const start = window.setTimeout(() => void sendScheduled(queryClient), 2000);
|
||||||
|
const every = window.setInterval(() => void sendScheduled(queryClient), 60_000);
|
||||||
|
// A scheduled message was looked at and closed: no need to wait a minute.
|
||||||
|
const now = () => void sendScheduled(queryClient);
|
||||||
|
window.addEventListener("webmail:send-scheduled", now);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(start);
|
||||||
|
window.clearInterval(every);
|
||||||
|
window.removeEventListener("webmail:send-scheduled", now);
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
// While offline: ask the server now and then whether it is back.
|
// While offline: ask the server now and then whether it is back.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!offline) return;
|
if (!offline) return;
|
||||||
@@ -151,10 +176,21 @@ export function OfflineController() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
showNotice("notAvailable", 2500);
|
showOfflineNotice("notAvailable", 2500);
|
||||||
|
};
|
||||||
|
// Actions started some other way (a keyboard shortcut, say) fail with
|
||||||
|
// "offline" somewhere unhandled: say so the same way, not as a page error.
|
||||||
|
const onRejection = (e: PromiseRejectionEvent) => {
|
||||||
|
if (!isOfflineError(e.reason)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
showOfflineNotice("notAvailable", 2500);
|
||||||
};
|
};
|
||||||
document.addEventListener("click", onClick, true);
|
document.addEventListener("click", onClick, true);
|
||||||
return () => document.removeEventListener("click", onClick, true);
|
window.addEventListener("unhandledrejection", onRejection);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", onClick, true);
|
||||||
|
window.removeEventListener("unhandledrejection", onRejection);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
@@ -164,14 +200,24 @@ export function OfflineController() {
|
|||||||
role="status"
|
role="status"
|
||||||
className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg"
|
className="wm-offline-notice pointer-events-none fixed bottom-6 left-1/2 z-[70] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2.5 rounded-xl border border-app-border bg-app-elevated px-4 py-2.5 text-sm text-app-fg shadow-lg"
|
||||||
>
|
>
|
||||||
<span className="text-amber-500">
|
{current.key === "scheduled" || current.key === "sendingScheduled" ? (
|
||||||
<CloudOff size={18} />
|
<span className="text-app-accent">
|
||||||
</span>
|
<Clock size={18} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-500">
|
||||||
|
<CloudOff size={18} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{current.key === "offline"
|
{current.key === "offline"
|
||||||
? t("offline.noticeSaved")
|
? t("offline.noticeSaved")
|
||||||
: current.key === "offlineNotSaved"
|
: current.key === "offlineNotSaved"
|
||||||
? t("offline.noticeNotSaved")
|
? t("offline.noticeNotSaved")
|
||||||
: t("offline.notAvailable")}
|
: current.key === "scheduled"
|
||||||
|
? t("offline.noticeScheduled")
|
||||||
|
: current.key === "sendingScheduled"
|
||||||
|
? t("offline.noticeSendingScheduled", current.vars)
|
||||||
|
: t("offline.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,7 +228,7 @@ export function OfflineIndicator({ compact = false }: { compact?: boolean }) {
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const offline = useOffline();
|
const offline = useOffline();
|
||||||
const enabled = useOfflineEnabled();
|
const enabled = useOfflineEnabled();
|
||||||
const back = useSyncExternalStore(subscribe, () => backOnline);
|
const back = useBackOnline();
|
||||||
const last = useLastSync();
|
const last = useLastSync();
|
||||||
const when = savedWhen(last);
|
const when = savedWhen(last);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -2,8 +2,22 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError, isOfflineError } from "../../lib/api";
|
||||||
|
import { isOffline, useOffline } from "../../lib/offline";
|
||||||
|
import {
|
||||||
|
deleteFiles,
|
||||||
|
deleteLocal,
|
||||||
|
holdOpen,
|
||||||
|
type LocalFile,
|
||||||
|
type LocalMessage,
|
||||||
|
type LocalState,
|
||||||
|
newLocalId,
|
||||||
|
putFile,
|
||||||
|
putLocal,
|
||||||
|
uploadFiles,
|
||||||
|
} from "../../lib/scheduled";
|
||||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||||
|
import { showOfflineNotice } from "../../lib/offlineNotice";
|
||||||
import { RecipientChips } from "./RecipientChips";
|
import { RecipientChips } from "./RecipientChips";
|
||||||
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
||||||
import { hasUndoHost, offerUndo } from "./undoSend";
|
import { hasUndoHost, offerUndo } from "./undoSend";
|
||||||
@@ -17,6 +31,7 @@ export interface ComposerInit {
|
|||||||
kind: string; // reply | reply_all | forward | new
|
kind: string; // reply | reply_all | forward | new
|
||||||
to?: string;
|
to?: string;
|
||||||
cc?: string;
|
cc?: string;
|
||||||
|
bcc?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
html?: string; // initial body when reopening an existing draft
|
html?: string; // initial body when reopening an existing draft
|
||||||
@@ -25,6 +40,11 @@ export interface ComposerInit {
|
|||||||
// A reply/forward quotes the original: this attribution line sits above the
|
// A reply/forward quotes the original: this attribution line sits above the
|
||||||
// quoted message body (fetched from the original message's sanitized HTML).
|
// quoted message body (fetched from the original message's sanitized HTML).
|
||||||
quote?: { attribution: string; messageId: string };
|
quote?: { attribution: string; messageId: string };
|
||||||
|
// A message kept on this device (written offline; lib/scheduled.ts).
|
||||||
|
localId?: string;
|
||||||
|
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||||
|
attachments?: AttachmentOut[]; // known already: no need to ask the server
|
||||||
|
localFiles?: LocalFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
||||||
@@ -45,6 +65,29 @@ export function draftToInit(draft: DraftOut): ComposerInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen a message kept on this device (a draft written offline, or one in
|
||||||
|
// Scheduled).
|
||||||
|
export function localToInit(msg: LocalMessage): ComposerInit {
|
||||||
|
const p = msg.input.payload;
|
||||||
|
return {
|
||||||
|
accountId: msg.input.account_id,
|
||||||
|
threadId: msg.input.thread_id ?? null,
|
||||||
|
replyToMessageId: msg.input.reply_to_message_id ?? null,
|
||||||
|
kind: msg.input.kind,
|
||||||
|
to: p.to.join(", "),
|
||||||
|
cc: p.cc.join(", "),
|
||||||
|
bcc: p.bcc.join(", "),
|
||||||
|
subject: p.subject,
|
||||||
|
text: p.text,
|
||||||
|
html: p.html || undefined,
|
||||||
|
draftId: msg.serverDraftId ?? undefined,
|
||||||
|
localId: msg.id,
|
||||||
|
localState: msg.state === "draft" ? "draft" : "scheduled", // "failed"/"sending" wait to be sent too
|
||||||
|
attachments: msg.attachments,
|
||||||
|
localFiles: msg.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function splitList(value: string): string[] {
|
function splitList(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -80,11 +123,13 @@ function HeaderButton({
|
|||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
active = false,
|
active = false,
|
||||||
|
className = "",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +138,7 @@ function HeaderButton({
|
|||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
className={`${className} flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
||||||
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -150,8 +195,8 @@ export function Composer({
|
|||||||
const [accountId, setAccountId] = useState(init.accountId);
|
const [accountId, setAccountId] = useState(init.accountId);
|
||||||
const [to, setTo] = useState(init.to ?? "");
|
const [to, setTo] = useState(init.to ?? "");
|
||||||
const [cc, setCc] = useState(init.cc ?? "");
|
const [cc, setCc] = useState(init.cc ?? "");
|
||||||
const [bcc, setBcc] = useState("");
|
const [bcc, setBcc] = useState(init.bcc ?? "");
|
||||||
const [showCc, setShowCc] = useState(Boolean(init.cc));
|
const [showCc, setShowCc] = useState(Boolean(init.cc || init.bcc));
|
||||||
const [subject, setSubject] = useState(init.subject ?? "");
|
const [subject, setSubject] = useState(init.subject ?? "");
|
||||||
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
|
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
|
||||||
const [quoteHtml, setQuoteHtml] = useState("");
|
const [quoteHtml, setQuoteHtml] = useState("");
|
||||||
@@ -176,9 +221,12 @@ export function Composer({
|
|||||||
});
|
});
|
||||||
const moreRef = useRef<HTMLDivElement>(null);
|
const moreRef = useRef<HTMLDivElement>(null);
|
||||||
const fromRef = useRef<HTMLDivElement>(null);
|
const fromRef = useRef<HTMLDivElement>(null);
|
||||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
const [attachments, setAttachments] = useState<AttachmentOut[]>(init.attachments ?? []);
|
||||||
|
// Files attached while offline, kept on this device until they can be uploaded.
|
||||||
|
const [localFiles, setLocalFiles] = useState<LocalFile[]>(init.localFiles ?? []);
|
||||||
const [attachError, setAttachError] = useState("");
|
const [attachError, setAttachError] = useState("");
|
||||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
const [status, setStatus] = useState<"" | "saving" | "saved" | "savedLocal" | "sending" | "error">("");
|
||||||
|
const offline = useOffline();
|
||||||
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
|
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
|
||||||
const [undoLeft, setUndoLeft] = useState(0);
|
const [undoLeft, setUndoLeft] = useState(0);
|
||||||
// Tick the undo countdown while the window is open.
|
// Tick the undo countdown while the window is open.
|
||||||
@@ -199,6 +247,11 @@ export function Composer({
|
|||||||
onTitleRef.current?.(subject);
|
onTitleRef.current?.(subject);
|
||||||
}, [subject]);
|
}, [subject]);
|
||||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||||
|
// This message's copy on this device, while there is one (lib/scheduled.ts).
|
||||||
|
const localId = useRef<string | null>(init.localId ?? null);
|
||||||
|
const discardedRef = useRef(false);
|
||||||
|
// Kept as what it was opened as: a scheduled message edited offline stays scheduled.
|
||||||
|
const localState = useRef<LocalState>(init.localState ?? "draft");
|
||||||
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
||||||
// Whether the user actually edited anything. Merely opening a draft to view it
|
// Whether the user actually edited anything. Merely opening a draft to view it
|
||||||
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
||||||
@@ -249,6 +302,8 @@ export function Composer({
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
|
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
|
||||||
|
// Offline, a message not saved here answers 503: quote nothing rather than that.
|
||||||
|
if (!resp.ok) throw new Error(`quote: ${resp.status}`);
|
||||||
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
||||||
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
|
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -265,7 +320,7 @@ export function Composer({
|
|||||||
// Resolve attachment metadata when reopening a draft that already has some.
|
// Resolve attachment metadata when reopening a draft that already has some.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = init.attachmentIds ?? [];
|
const ids = init.attachmentIds ?? [];
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0 || init.attachments) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -290,6 +345,12 @@ export function Composer({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (isOffline()) {
|
||||||
|
// Kept on this device, uploaded once the server is reachable.
|
||||||
|
const kept = await putFile(file);
|
||||||
|
setLocalFiles((prev) => [...prev, kept]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const att = await api.uploadComposeAttachment(file);
|
const att = await api.uploadComposeAttachment(file);
|
||||||
setAttachments((prev) => [...prev, att]);
|
setAttachments((prev) => [...prev, att]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -303,6 +364,12 @@ export function Composer({
|
|||||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeLocalFile = (id: string) => {
|
||||||
|
markDirty();
|
||||||
|
setLocalFiles((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
void deleteFiles([id]);
|
||||||
|
};
|
||||||
|
|
||||||
// Drag & drop: files dropped on the writing area insert inline (images) or
|
// Drag & drop: files dropped on the writing area insert inline (images) or
|
||||||
// attach (other types); dropped anywhere above it, everything attaches.
|
// attach (other types); dropped anywhere above it, everything attaches.
|
||||||
const editorAreaRef = useRef<HTMLDivElement>(null);
|
const editorAreaRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -393,7 +460,7 @@ export function Composer({
|
|||||||
|
|
||||||
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
|
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
|
||||||
|
|
||||||
const payload = (): DraftInput => {
|
const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
|
||||||
const html = bodyHtml || initialHtml;
|
const html = bodyHtml || initialHtml;
|
||||||
return {
|
return {
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
@@ -407,31 +474,94 @@ export function Composer({
|
|||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text: htmlToText(html),
|
text: htmlToText(html),
|
||||||
attachment_ids: attachments.map((a) => a.id),
|
attachment_ids: atts.map((a) => a.id),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// Keep this message on the device (offline): as a draft, or for Scheduled.
|
||||||
if (undo) return;
|
const saveLocal = async (state: LocalState) => {
|
||||||
const handle = window.setTimeout(async () => {
|
localId.current ??= newLocalId();
|
||||||
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
const msg: LocalMessage = {
|
||||||
if (!to && !subject && !bodyHtml) return;
|
id: localId.current,
|
||||||
setStatus("saving");
|
state,
|
||||||
|
input: payload(),
|
||||||
|
attachments,
|
||||||
|
files: localFiles,
|
||||||
|
serverDraftId: draftId.current,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await putLocal(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// While a copy is kept here, other tabs' hand-over to the server leaves it be.
|
||||||
|
const [heldId, setHeldId] = useState(localId.current);
|
||||||
|
useEffect(() => (heldId ? holdOpen(heldId) : undefined), [heldId]);
|
||||||
|
|
||||||
|
// Save to the server; a copy kept on this device goes there first (its files
|
||||||
|
// uploaded), then is dropped. Returns the attachments now on the server.
|
||||||
|
const saveToServer = async (): Promise<AttachmentOut[]> => {
|
||||||
|
let atts = attachments;
|
||||||
|
if (localFiles.length) {
|
||||||
|
const uploaded = await uploadFiles(localFiles);
|
||||||
|
await deleteFiles(localFiles.map((f) => f.id));
|
||||||
|
atts = [...attachments, ...uploaded];
|
||||||
|
setAttachments(atts);
|
||||||
|
setLocalFiles([]);
|
||||||
|
}
|
||||||
|
if (draftId.current) await api.updateDraft(draftId.current, payload(atts));
|
||||||
|
else draftId.current = (await api.createDraft(payload(atts))).id;
|
||||||
|
if (localId.current) {
|
||||||
|
await deleteLocal(localId.current);
|
||||||
|
localId.current = null;
|
||||||
|
setHeldId(null);
|
||||||
|
}
|
||||||
|
localState.current = "draft"; // on the server now: a draft until Send
|
||||||
|
return atts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const autosave = async () => {
|
||||||
|
setStatus("saving");
|
||||||
|
try {
|
||||||
|
if (isOffline()) throw new ApiError(0, "offline", "offline");
|
||||||
|
await saveToServer();
|
||||||
|
setStatus("saved");
|
||||||
|
} catch (e) {
|
||||||
|
if (!isOfflineError(e)) {
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (draftId.current) {
|
await saveLocal(localState.current);
|
||||||
await api.updateDraft(draftId.current, payload());
|
setHeldId(localId.current);
|
||||||
} else {
|
setStatus("savedLocal");
|
||||||
draftId.current = (await api.createDraft(payload())).id;
|
|
||||||
}
|
|
||||||
setStatus("saved");
|
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (undo) return;
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
||||||
|
if (!to && !subject && !bodyHtml) return;
|
||||||
|
void autosave();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => window.clearTimeout(handle);
|
return () => window.clearTimeout(handle);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
|
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments, localFiles]);
|
||||||
|
|
||||||
|
// Back online with a draft kept here: hand it to the server now. (A
|
||||||
|
// scheduled one is sent by the scheduled sender once this window closes.)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offline && localId.current && !undo && localState.current === "draft") void autosave();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [offline]);
|
||||||
|
|
||||||
|
// The latest way to save locally, for the unmount below (its closure is old).
|
||||||
|
const saveLocalRef = useRef(saveLocal);
|
||||||
|
saveLocalRef.current = saveLocal;
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -439,25 +569,57 @@ export function Composer({
|
|||||||
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
||||||
// continued on any mail client — but only if the user actually edited it
|
// continued on any mail client — but only if the user actually edited it
|
||||||
// (skip if it was sent, discarded, or merely opened to view).
|
// (skip if it was sent, discarded, or merely opened to view).
|
||||||
if (draftId.current && !sentRef.current && dirtyRef.current) {
|
if (localId.current && localState.current === "scheduled" && !sentRef.current && !discardedRef.current) {
|
||||||
|
// A scheduled message looked at (or edited offline) and closed: still
|
||||||
|
// scheduled — save any change, then let the sender have it.
|
||||||
|
const save = dirtyRef.current ? saveLocalRef.current("scheduled") : Promise.resolve();
|
||||||
|
void save.finally(() => window.dispatchEvent(new Event("webmail:send-scheduled")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sentRef.current || discardedRef.current || !dirtyRef.current) return;
|
||||||
|
if (isOffline()) {
|
||||||
|
// Closed offline: keep it here as a draft (the server gets it later).
|
||||||
|
void saveLocalRef.current("draft").catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draftId.current && !localId.current) {
|
||||||
void api.publishDraft(draftId.current).catch(() => {});
|
void api.publishDraft(draftId.current).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Offline (or the connection dropped while sending): into Scheduled, to go
|
||||||
|
// out on its own once the server is reachable again.
|
||||||
|
const schedule = async () => {
|
||||||
|
try {
|
||||||
|
await saveLocal("scheduled");
|
||||||
|
sentRef.current = true;
|
||||||
|
showOfflineNotice("scheduled", 5000);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
sentRef.current = false;
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
setStatus("sending");
|
setStatus("sending");
|
||||||
sentRef.current = true; // don't also mirror to Drafts on close
|
sentRef.current = true; // don't also mirror to Drafts on close
|
||||||
|
if (isOffline()) {
|
||||||
|
await schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
await saveToServer();
|
||||||
else await api.updateDraft(draftId.current, payload());
|
const sentDraft = draftId.current!;
|
||||||
const res = await api.send(draftId.current);
|
const res = await api.send(sentDraft);
|
||||||
// Drive the countdown + auto-close off the server's undo deadline.
|
// Drive the countdown + auto-close off the server's undo window, as a
|
||||||
const until = new Date(res.undo_until).getTime();
|
// duration: this computer's clock may be seconds off the server's.
|
||||||
|
const until = Date.now() + res.undo_seconds * 1000;
|
||||||
// With a toast to carry the undo offer, the window closes right away.
|
// With a toast to carry the undo offer, the window closes right away.
|
||||||
if (hasUndoHost()) {
|
if (hasUndoHost()) {
|
||||||
offerUndo({ outboxId: res.outbox_id, draftId: draftId.current, until });
|
offerUndo({ outboxId: res.outbox_id, draftId: sentDraft, until });
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -466,6 +628,10 @@ export function Composer({
|
|||||||
setUndoLeft(Math.ceil(remaining / 1000));
|
setUndoLeft(Math.ceil(remaining / 1000));
|
||||||
undoTimer.current = window.setTimeout(onClose, remaining);
|
undoTimer.current = window.setTimeout(onClose, remaining);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isOfflineError(e)) {
|
||||||
|
await schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
sentRef.current = false;
|
sentRef.current = false;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
if (!(e instanceof ApiError)) throw e;
|
if (!(e instanceof ApiError)) throw e;
|
||||||
@@ -486,7 +652,14 @@ export function Composer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const discard = async () => {
|
const discard = async () => {
|
||||||
if (draftId.current) {
|
discardedRef.current = true;
|
||||||
|
if (localId.current) {
|
||||||
|
await deleteLocal(localId.current).catch(() => {});
|
||||||
|
localId.current = null;
|
||||||
|
} else if (localFiles.length) {
|
||||||
|
await deleteFiles(localFiles.map((f) => f.id)).catch(() => {});
|
||||||
|
}
|
||||||
|
if (draftId.current && !isOffline()) {
|
||||||
try {
|
try {
|
||||||
await api.deleteDraft(draftId.current);
|
await api.deleteDraft(draftId.current);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -500,7 +673,7 @@ export function Composer({
|
|||||||
// X button: if there's a draft worth keeping, ask before closing; otherwise
|
// X button: if there's a draft worth keeping, ask before closing; otherwise
|
||||||
// (nothing typed, no draft) just close.
|
// (nothing typed, no draft) just close.
|
||||||
const requestClose = () => {
|
const requestClose = () => {
|
||||||
if (!dirtyRef.current && !draftId.current) {
|
if (!dirtyRef.current && !draftId.current && !localId.current) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -513,10 +686,7 @@ export function Composer({
|
|||||||
const saveDraftAndClose = async () => {
|
const saveDraftAndClose = async () => {
|
||||||
setShowClosePrompt(false);
|
setShowClosePrompt(false);
|
||||||
try {
|
try {
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) await autosave();
|
||||||
if (draftId.current) await api.updateDraft(draftId.current, payload());
|
|
||||||
else draftId.current = (await api.createDraft(payload())).id;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* the close-time IMAP mirror is best-effort too */
|
/* the close-time IMAP mirror is best-effort too */
|
||||||
}
|
}
|
||||||
@@ -639,7 +809,7 @@ export function Composer({
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Phone width: the icon alone. */}
|
{/* Phone width: the icon alone. */}
|
||||||
<span className="max-md:hidden">
|
<span className="max-md:hidden">
|
||||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
{status === "sending" ? t("compose.sending") : offline ? t("compose.sendLater") : t("compose.send")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
||||||
@@ -758,6 +928,7 @@ export function Composer({
|
|||||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
|
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
|
||||||
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||||
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
||||||
|
{!minimized && status === "savedLocal" && <span className="mr-1">{t("compose.savedOnDevice")}</span>}
|
||||||
{!minimized && status === "error" && (
|
{!minimized && status === "error" && (
|
||||||
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
|
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -793,7 +964,7 @@ export function Composer({
|
|||||||
{!fullWindow && (
|
{!fullWindow && (
|
||||||
// No second browser window to pop out into on a phone.
|
// No second browser window to pop out into on a phone.
|
||||||
<div className="contents max-md:hidden">
|
<div className="contents max-md:hidden">
|
||||||
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
|
<HeaderButton onClick={popOut} title={t("compose.popOut")} className="wm-online-only">
|
||||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -898,7 +1069,7 @@ export function Composer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(attachments.length > 0 || attachError) && (
|
{(attachments.length > 0 || localFiles.length > 0 || attachError) && (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
|
||||||
{attachments.map((att) => (
|
{attachments.map((att) => (
|
||||||
<span
|
<span
|
||||||
@@ -920,6 +1091,27 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{localFiles.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.id}
|
||||||
|
title={t("compose.attachOnDevice")}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-dashed border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||||
|
</svg>
|
||||||
|
<span className="max-w-[12rem] truncate">{f.name}</span>
|
||||||
|
<span className="text-app-faint">{formatBytes(f.size)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeLocalFile(f.id)}
|
||||||
|
className="text-app-faint hover:text-app-fg"
|
||||||
|
aria-label={`remove ${f.name}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { isOffline } from "../../lib/offline";
|
||||||
import type { RecipientSuggestion } from "../../lib/types";
|
import type { RecipientSuggestion } from "../../lib/types";
|
||||||
|
|
||||||
function splitList(value: string): string[] {
|
function splitList(value: string): string[] {
|
||||||
@@ -76,7 +77,8 @@ export function RecipientChips({
|
|||||||
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
|
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft.trim().length < 2) {
|
// Suggestions come from the server: none offline (and no doomed request).
|
||||||
|
if (draft.trim().length < 2 || isOffline()) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||||
|
import type { LocalMessage } from "../../lib/scheduled";
|
||||||
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
|
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
|
||||||
|
|
||||||
function timeLabel(iso: string): string {
|
function timeLabel(iso: string): string {
|
||||||
@@ -15,6 +16,7 @@ function DraftRow({
|
|||||||
preview,
|
preview,
|
||||||
time,
|
time,
|
||||||
badge,
|
badge,
|
||||||
|
badgeTone,
|
||||||
selected,
|
selected,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
@@ -23,9 +25,16 @@ function DraftRow({
|
|||||||
preview: string;
|
preview: string;
|
||||||
time: string;
|
time: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
|
badgeTone?: "muted" | "accent" | "error";
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const tone =
|
||||||
|
badgeTone === "accent"
|
||||||
|
? "bg-app-accent/15 text-app-accent"
|
||||||
|
: badgeTone === "error"
|
||||||
|
? "bg-red-500/15 text-red-500"
|
||||||
|
: "bg-app-hover text-app-muted";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -36,7 +45,7 @@ function DraftRow({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
|
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
|
||||||
{badge && (
|
{badge && (
|
||||||
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[12px] text-app-muted">
|
<span className={`shrink-0 rounded px-1.5 text-[12px] ${tone}`}>
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -53,20 +62,28 @@ function DraftRow({
|
|||||||
export function DraftsList({
|
export function DraftsList({
|
||||||
drafts,
|
drafts,
|
||||||
remoteDrafts,
|
remoteDrafts,
|
||||||
|
deviceDrafts = [],
|
||||||
|
note,
|
||||||
selectedId,
|
selectedId,
|
||||||
onOpen,
|
onOpen,
|
||||||
onOpenRemote,
|
onOpenRemote,
|
||||||
|
onOpenDevice,
|
||||||
}: {
|
}: {
|
||||||
drafts: DraftOut[];
|
drafts: DraftOut[];
|
||||||
remoteDrafts: RemoteDraftOut[];
|
remoteDrafts: RemoteDraftOut[];
|
||||||
|
/** Drafts written offline, kept on this device until the server has them. */
|
||||||
|
deviceDrafts?: LocalMessage[];
|
||||||
|
/** A line above the list (offline: only this device's drafts are here). */
|
||||||
|
note?: string;
|
||||||
/** Id of the draft currently loaded in the reading pane. */
|
/** Id of the draft currently loaded in the reading pane. */
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onOpen: (draft: DraftOut) => void;
|
onOpen: (draft: DraftOut) => void;
|
||||||
onOpenRemote: (draft: RemoteDraftOut) => void;
|
onOpenRemote: (draft: RemoteDraftOut) => void;
|
||||||
|
onOpenDevice?: (msg: LocalMessage) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
if (drafts.length === 0 && remoteDrafts.length === 0) {
|
if (drafts.length === 0 && remoteDrafts.length === 0 && deviceDrafts.length === 0) {
|
||||||
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
|
return <div className="p-6 text-sm text-app-faint">{note ?? t("compose.noDrafts")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge local + remote drafts into one list sorted by date (newest first).
|
// Merge local + remote drafts into one list sorted by date (newest first).
|
||||||
@@ -80,8 +97,20 @@ export function DraftsList({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
// A device draft continuing a server draft stands in for it.
|
||||||
|
const onDevice = new Set(deviceDrafts.map((m) => m.serverDraftId).filter(Boolean));
|
||||||
const rows: Row[] = [
|
const rows: Row[] = [
|
||||||
...drafts.map((d) => ({
|
...deviceDrafts.map((m) => ({
|
||||||
|
key: `d-${m.id}`,
|
||||||
|
date: new Date(m.updatedAt).toISOString(),
|
||||||
|
recipients: m.input.payload.to.join(", ") || t("compose.noRecipient"),
|
||||||
|
subject: m.input.payload.subject || t("compose.noSubject"),
|
||||||
|
preview: m.input.payload.text.trim(),
|
||||||
|
badge: t("compose.onDevice"),
|
||||||
|
selected: m.id === selectedId,
|
||||||
|
onClick: () => onOpenDevice?.(m),
|
||||||
|
})),
|
||||||
|
...drafts.filter((d) => !onDevice.has(d.id)).map((d) => ({
|
||||||
key: `l-${d.id}`,
|
key: `l-${d.id}`,
|
||||||
date: d.updated_at,
|
date: d.updated_at,
|
||||||
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
|
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
|
||||||
@@ -106,6 +135,7 @@ export function DraftsList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto" data-testid="drafts-list">
|
<div className="h-full overflow-y-auto" data-testid="drafts-list">
|
||||||
|
{note && <div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{note}</div>}
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<DraftRow
|
<DraftRow
|
||||||
key={row.key}
|
key={row.key}
|
||||||
@@ -121,3 +151,43 @@ export function DraftsList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Scheduled folder: messages sent while offline, waiting for the connection.
|
||||||
|
export function ScheduledList({
|
||||||
|
messages,
|
||||||
|
selectedId,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
messages: LocalMessage[];
|
||||||
|
selectedId?: string;
|
||||||
|
onOpen: (msg: LocalMessage) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return <div className="p-6 text-sm text-app-faint">{t("scheduled.empty")}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto" data-testid="scheduled-list">
|
||||||
|
<div className="border-b border-app-border px-6 py-2 text-xs text-app-faint">{t("scheduled.help")}</div>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<DraftRow
|
||||||
|
key={m.id}
|
||||||
|
recipients={m.input.payload.to.join(", ") || t("compose.noRecipient")}
|
||||||
|
subject={m.input.payload.subject || t("compose.noSubject")}
|
||||||
|
preview={m.state === "failed" && m.error ? m.error : m.input.payload.text.trim()}
|
||||||
|
time={timeLabel(new Date(m.updatedAt).toISOString())}
|
||||||
|
badge={
|
||||||
|
m.state === "failed"
|
||||||
|
? t("scheduled.failed")
|
||||||
|
: m.state === "sending"
|
||||||
|
? t("scheduled.sending")
|
||||||
|
: t("scheduled.waiting")
|
||||||
|
}
|
||||||
|
badgeTone={m.state === "failed" ? "error" : "accent"}
|
||||||
|
selected={m.id === selectedId}
|
||||||
|
onClick={() => onOpen(m)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ function MessageItem({
|
|||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||||
<button
|
<button
|
||||||
className="wm-online-only flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||||
onClick={() => onReply(kind)}
|
onClick={() => onReply(kind)}
|
||||||
>
|
>
|
||||||
<Icon name={icon} size={14} />
|
<Icon name={icon} size={14} />
|
||||||
@@ -442,7 +442,7 @@ function MessageItem({
|
|||||||
</button>
|
</button>
|
||||||
{canReply && (
|
{canReply && (
|
||||||
<button
|
<button
|
||||||
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
|
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||||
>
|
>
|
||||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||||
|
|||||||
@@ -556,6 +556,19 @@ export const en = {
|
|||||||
"offline.notAvailable": "Not available offline",
|
"offline.notAvailable": "Not available offline",
|
||||||
"offline.threadNotSaved": "This conversation isn't saved on this device.",
|
"offline.threadNotSaved": "This conversation isn't saved on this device.",
|
||||||
"offline.folderNotSaved": "This folder isn't saved on this device.",
|
"offline.folderNotSaved": "This folder isn't saved on this device.",
|
||||||
|
"offline.noticeScheduled": "Saved to Scheduled — it will be sent when you're back online.",
|
||||||
|
"offline.noticeSendingScheduled": "Sending {count} scheduled messages…",
|
||||||
|
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
|
||||||
|
"compose.sendLater": "Schedule",
|
||||||
|
"compose.savedOnDevice": "Saved on this device",
|
||||||
|
"compose.attachOnDevice": "On this device — uploaded when you're back online",
|
||||||
|
"compose.onDevice": "On this device",
|
||||||
|
"nav.scheduled": "Scheduled",
|
||||||
|
"scheduled.help": "Sent automatically when you're back online.",
|
||||||
|
"scheduled.empty": "Nothing scheduled.",
|
||||||
|
"scheduled.waiting": "Waiting",
|
||||||
|
"scheduled.sending": "Sending…",
|
||||||
|
"scheduled.failed": "Not sent",
|
||||||
"offline.searchPlaceholder": "Search (offline)",
|
"offline.searchPlaceholder": "Search (offline)",
|
||||||
"offline.cantReach": "Can't reach the server.",
|
"offline.cantReach": "Can't reach the server.",
|
||||||
"offline.settingTitle": "Offline access",
|
"offline.settingTitle": "Offline access",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type QueryClient, useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { api, ApiError } from "./api";
|
import { api, ApiError } from "./api";
|
||||||
import { clearOfflineData } from "./offline";
|
import { clearOfflineData } from "./offline";
|
||||||
|
import { wipeLocal } from "./scheduled";
|
||||||
|
|
||||||
export function useMe() {
|
export function useMe() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -26,6 +27,7 @@ export function signedOut(queryClient: QueryClient) {
|
|||||||
// whole cache first would orphan them from the query they watch.
|
// whole cache first would orphan them from the query they watch.
|
||||||
queryClient.setQueryData(["me"], null);
|
queryClient.setQueryData(["me"], null);
|
||||||
void clearOfflineData(); // mail saved for offline reading goes with the session
|
void clearOfflineData(); // mail saved for offline reading goes with the session
|
||||||
|
void wipeLocal(); // and so does anything written offline
|
||||||
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// The short-lived offline messages (components/Offline.tsx shows them): the
|
||||||
|
// floating notice at the bottom, and the green "Back online" in the sidebar.
|
||||||
|
// Their own module so anything — the composer, the scheduled sender — can raise one.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export type NoticeKey = "offline" | "offlineNotSaved" | "notAvailable" | "scheduled" | "sendingScheduled";
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
key: NoticeKey;
|
||||||
|
vars?: Record<string, string | number>;
|
||||||
|
seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notice: Notice | null = null;
|
||||||
|
let backOnline = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const emit = () => listeners.forEach((l) => l());
|
||||||
|
const subscribe = (l: () => void) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let noticeTimer: number | undefined;
|
||||||
|
let noticeSeq = 0;
|
||||||
|
|
||||||
|
export function showOfflineNotice(key: NoticeKey, ms: number, vars?: Notice["vars"]): void {
|
||||||
|
notice = { key, vars, seq: ++noticeSeq };
|
||||||
|
emit();
|
||||||
|
window.clearTimeout(noticeTimer);
|
||||||
|
noticeTimer = window.setTimeout(() => {
|
||||||
|
notice = null;
|
||||||
|
emit();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOfflineNotice(): void {
|
||||||
|
notice = null;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOfflineNotice(): Notice | null {
|
||||||
|
return useSyncExternalStore(subscribe, () => notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackOnline(value: boolean): void {
|
||||||
|
backOnline = value;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBackOnline(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, () => backOnline);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// Messages written while offline, kept on this device (IndexedDB) until the
|
||||||
|
// server can take them:
|
||||||
|
// - "draft": written offline and not sent — becomes a normal server draft
|
||||||
|
// once the connection is back;
|
||||||
|
// - "scheduled": Send was pressed offline — sent as soon as the connection is
|
||||||
|
// back (the "Scheduled" folder);
|
||||||
|
// - "sending": being handed to the server right now;
|
||||||
|
// - "failed": the server refused it (shown in Scheduled with the reason).
|
||||||
|
//
|
||||||
|
// Files attached offline are kept here too, and uploaded when the message goes
|
||||||
|
// to the server. Nothing here leaves this device until then.
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { api, ApiError, isOfflineError } from "./api";
|
||||||
|
import type { AttachmentOut, DraftInput } from "./types";
|
||||||
|
|
||||||
|
export type LocalState = "draft" | "scheduled" | "sending" | "failed";
|
||||||
|
|
||||||
|
export interface LocalFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMessage {
|
||||||
|
id: string;
|
||||||
|
state: LocalState;
|
||||||
|
/** The draft as the server takes it; attachment_ids are server attachments. */
|
||||||
|
input: DraftInput;
|
||||||
|
/** Those server attachments' names and sizes, to show them offline. */
|
||||||
|
attachments: AttachmentOut[];
|
||||||
|
/** Files attached offline, uploaded when the message reaches the server. */
|
||||||
|
files: LocalFile[];
|
||||||
|
/** The server draft this continues (opened from Drafts, then went offline). */
|
||||||
|
serverDraftId: string | null;
|
||||||
|
error?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledSent {
|
||||||
|
outboxId: string;
|
||||||
|
draftId: string;
|
||||||
|
undoSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = "wm-offline";
|
||||||
|
const MESSAGES = "messages";
|
||||||
|
const FILES = "files";
|
||||||
|
|
||||||
|
// ---- IndexedDB, promised ---------------------------------------------------------
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function db(): Promise<IDBDatabase> {
|
||||||
|
dbPromise ??= new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
req.result.createObjectStore(MESSAGES, { keyPath: "id" });
|
||||||
|
req.result.createObjectStore(FILES, { keyPath: "id" });
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => {
|
||||||
|
dbPromise = null;
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function done<T>(req: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store(name: string, mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
||||||
|
return (await db()).transaction(name, mode).objectStore(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- change notification (this tab, and the others) -------------------------------
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const channel = "BroadcastChannel" in window ? new BroadcastChannel("wm-local-messages") : null;
|
||||||
|
let snapshot: LocalMessage[] = [];
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const all = (await done((await store(MESSAGES, "readonly")).getAll())) as LocalMessage[];
|
||||||
|
snapshot = all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
} catch {
|
||||||
|
snapshot = [];
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
for (const l of listeners) l();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed(): void {
|
||||||
|
channel?.postMessage("changed");
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
channel?.addEventListener("message", () => void reload());
|
||||||
|
|
||||||
|
/** Everything kept on this device, newest first (live). */
|
||||||
|
export function useLocalMessages(): LocalMessage[] {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) void reload();
|
||||||
|
}, []);
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => snapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reading and writing ---------------------------------------------------------
|
||||||
|
|
||||||
|
export function newLocalId(): string {
|
||||||
|
return `local-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocal(): Promise<LocalMessage[]> {
|
||||||
|
return (await done((await store(MESSAGES, "readonly")).getAll())) as LocalMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putLocal(msg: LocalMessage): Promise<void> {
|
||||||
|
await done((await store(MESSAGES, "readwrite")).put(msg));
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocal(id: string): Promise<void> {
|
||||||
|
const msg = (await done((await store(MESSAGES, "readonly")).get(id))) as LocalMessage | undefined;
|
||||||
|
if (msg) await deleteFiles(msg.files.map((f) => f.id));
|
||||||
|
await done((await store(MESSAGES, "readwrite")).delete(id));
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep a file attached offline; returns what the composer shows for it. */
|
||||||
|
export async function putFile(file: File): Promise<LocalFile> {
|
||||||
|
const meta = { id: crypto.randomUUID(), name: file.name, type: file.type, size: file.size };
|
||||||
|
await done((await store(FILES, "readwrite")).put({ ...meta, blob: file }));
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFiles(ids: string[]): Promise<void> {
|
||||||
|
if (!ids.length) return;
|
||||||
|
const s = await store(FILES, "readwrite");
|
||||||
|
await Promise.all(ids.map((id) => done(s.delete(id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFile(meta: LocalFile): Promise<File | null> {
|
||||||
|
const row = (await done((await store(FILES, "readonly")).get(meta.id))) as
|
||||||
|
| (LocalFile & { blob: Blob })
|
||||||
|
| undefined;
|
||||||
|
return row ? new File([row.blob], meta.name, { type: meta.type }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload files attached offline; returns the server attachments they became. */
|
||||||
|
export async function uploadFiles(files: LocalFile[]): Promise<AttachmentOut[]> {
|
||||||
|
const out: AttachmentOut[] = [];
|
||||||
|
for (const meta of files) {
|
||||||
|
const file = await getFile(meta);
|
||||||
|
if (file) out.push(await api.uploadComposeAttachment(file));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget everything kept here (signed out: it must not outlive the session). */
|
||||||
|
export async function wipeLocal(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await done((await store(MESSAGES, "readwrite")).clear());
|
||||||
|
await done((await store(FILES, "readwrite")).clear());
|
||||||
|
} catch {
|
||||||
|
/* nothing kept */
|
||||||
|
}
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composer windows holding a local message open: those save it themselves.
|
||||||
|
const openHere = new Set<string>();
|
||||||
|
|
||||||
|
export function holdOpen(id: string): () => void {
|
||||||
|
openHere.add(id);
|
||||||
|
return () => {
|
||||||
|
openHere.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- handing everything to the server -------------------------------------------
|
||||||
|
|
||||||
|
/** Make a local message a server draft (files uploaded first); returns its id. */
|
||||||
|
async function toServerDraft(msg: LocalMessage): Promise<string> {
|
||||||
|
if (msg.files.length) {
|
||||||
|
const uploaded = await uploadFiles(msg.files);
|
||||||
|
await deleteFiles(msg.files.map((f) => f.id));
|
||||||
|
msg.attachments = [...msg.attachments, ...uploaded];
|
||||||
|
msg.input = {
|
||||||
|
...msg.input,
|
||||||
|
payload: { ...msg.input.payload, attachment_ids: msg.attachments.map((a) => a.id) },
|
||||||
|
};
|
||||||
|
msg.files = [];
|
||||||
|
await putLocal(msg); // never upload the same files twice
|
||||||
|
}
|
||||||
|
if (msg.serverDraftId) {
|
||||||
|
await api.updateDraft(msg.serverDraftId, msg.input);
|
||||||
|
} else {
|
||||||
|
msg.serverDraftId = (await api.createDraft(msg.input)).id;
|
||||||
|
await putLocal(msg); // and never create the draft twice
|
||||||
|
}
|
||||||
|
return msg.serverDraftId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushOne(msg: LocalMessage, sent: ScheduledSent[]): Promise<void> {
|
||||||
|
if (msg.state === "draft") {
|
||||||
|
const id = await toServerDraft(msg);
|
||||||
|
void api.publishDraft(id).catch(() => {}); // like a composer closed online
|
||||||
|
await deleteLocal(msg.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.state = "sending";
|
||||||
|
await putLocal(msg);
|
||||||
|
try {
|
||||||
|
const draftId = await toServerDraft(msg);
|
||||||
|
const res = await api.send(draftId);
|
||||||
|
await deleteLocal(msg.id);
|
||||||
|
sent.push({ outboxId: res.outbox_id, draftId, undoSeconds: res.undo_seconds });
|
||||||
|
} catch (err) {
|
||||||
|
msg.state = isOfflineError(err) ? "scheduled" : "failed";
|
||||||
|
msg.error = err instanceof ApiError ? err.message : String(err);
|
||||||
|
await putLocal(msg);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushing: Promise<ScheduledSent[]> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand everything kept here to the server: scheduled messages are sent, drafts
|
||||||
|
* become server drafts. Under a lock, so two open tabs never send one message
|
||||||
|
* twice. Stops at the first sign of being offline again.
|
||||||
|
*/
|
||||||
|
export function flushLocal(): Promise<ScheduledSent[]> {
|
||||||
|
const run = async (): Promise<ScheduledSent[]> => {
|
||||||
|
const sent: ScheduledSent[] = [];
|
||||||
|
const all = (await listLocal()).sort((a, b) => a.updatedAt - b.updatedAt);
|
||||||
|
for (const msg of all) {
|
||||||
|
if (msg.state === "failed") continue;
|
||||||
|
if (openHere.has(msg.id)) continue; // open in a composer here: it decides
|
||||||
|
try {
|
||||||
|
await flushOne(msg, sent);
|
||||||
|
} catch (err) {
|
||||||
|
if (isOfflineError(err)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
};
|
||||||
|
flushing ??= (async () => {
|
||||||
|
try {
|
||||||
|
const locks = (navigator as Navigator & { locks?: LockManager }).locks;
|
||||||
|
return locks ? await locks.request("wm-local-flush", run) : await run();
|
||||||
|
} finally {
|
||||||
|
flushing = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
@@ -308,6 +308,8 @@ export interface DraftOut {
|
|||||||
export interface SendResponse {
|
export interface SendResponse {
|
||||||
outbox_id: string;
|
outbox_id: string;
|
||||||
undo_until: string;
|
undo_until: string;
|
||||||
|
/** Seconds left in the undo window (count down from this, not the clock). */
|
||||||
|
undo_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteDraftOut {
|
export interface RemoteDraftOut {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
||||||
import { AppSwitcher } from "../components/AppSwitcher";
|
import { AppSwitcher } from "../components/AppSwitcher";
|
||||||
import { ComposeDock } from "../components/compose/ComposeDock";
|
import { ComposeDock } from "../components/compose/ComposeDock";
|
||||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
|
||||||
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
||||||
import { usePendingSend } from "../components/compose/undoSend";
|
import { usePendingSend } from "../components/compose/undoSend";
|
||||||
import {
|
import {
|
||||||
@@ -17,14 +17,14 @@ import {
|
|||||||
restoreWindow,
|
restoreWindow,
|
||||||
toggleExpand,
|
toggleExpand,
|
||||||
} from "../components/compose/windows";
|
} from "../components/compose/windows";
|
||||||
import { DraftsList } from "../components/list/DraftsList";
|
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
import { MessageList } from "../components/list/MessageList";
|
import { MessageList } from "../components/list/MessageList";
|
||||||
import { Resizer } from "../components/Resizer";
|
import { Resizer } from "../components/Resizer";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
import { EmptyIllustration } from "../components/EmptyIllustration";
|
import { EmptyIllustration } from "../components/EmptyIllustration";
|
||||||
import { BackfillBanner } from "../components/BackfillBanner";
|
import { BackfillBanner } from "../components/BackfillBanner";
|
||||||
import { NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
import { Clock, NotSavedOffline, OfflineIndicator } from "../components/Offline";
|
||||||
import { ThreadView } from "../components/thread/ThreadView";
|
import { ThreadView } from "../components/thread/ThreadView";
|
||||||
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
|
||||||
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
|
||||||
@@ -32,6 +32,7 @@ import { useT } from "../i18n";
|
|||||||
import type { MessageKey } from "../i18n/en";
|
import type { MessageKey } from "../i18n/en";
|
||||||
import { api, isOfflineError } from "../lib/api";
|
import { api, isOfflineError } from "../lib/api";
|
||||||
import { clearOfflineData, useOffline } from "../lib/offline";
|
import { clearOfflineData, useOffline } from "../lib/offline";
|
||||||
|
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
|
||||||
import { setTimeFormat } from "../lib/timeFormat";
|
import { setTimeFormat } from "../lib/timeFormat";
|
||||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||||
import { useReadingPane } from "../lib/readingPane";
|
import { useReadingPane } from "../lib/readingPane";
|
||||||
@@ -217,6 +218,12 @@ export function MailLayout() {
|
|||||||
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||||||
|
|
||||||
const isDrafts = view === "folder:drafts";
|
const isDrafts = view === "folder:drafts";
|
||||||
|
// Messages written offline: drafts kept on this device, and the Scheduled
|
||||||
|
// folder of ones sent offline (lib/scheduled.ts).
|
||||||
|
const isScheduled = view === "local:scheduled";
|
||||||
|
const localMessages = useLocalMessages();
|
||||||
|
const deviceDrafts = localMessages.filter((m) => m.state === "draft");
|
||||||
|
const scheduled = localMessages.filter((m) => m.state !== "draft");
|
||||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||||
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
||||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
@@ -233,7 +240,7 @@ export function MailLayout() {
|
|||||||
}
|
}
|
||||||
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
|
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
|
||||||
},
|
},
|
||||||
enabled: !isDrafts && !searching,
|
enabled: !isDrafts && !isScheduled && !searching,
|
||||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
||||||
});
|
});
|
||||||
const draftsQuery = useQuery({
|
const draftsQuery = useQuery({
|
||||||
@@ -798,7 +805,7 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
||||||
<button
|
<button
|
||||||
className="wm-online-only flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||||
title={collapsed ? t("nav.compose") : undefined}
|
title={collapsed ? t("nav.compose") : undefined}
|
||||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||||
>
|
>
|
||||||
@@ -812,11 +819,11 @@ export function MailLayout() {
|
|||||||
f.view === "folder:inbox"
|
f.view === "folder:inbox"
|
||||||
? countsQuery.data?.inbox_unread
|
? countsQuery.data?.inbox_unread
|
||||||
: f.view === "folder:drafts"
|
: f.view === "folder:drafts"
|
||||||
? countsQuery.data?.drafts
|
? (countsQuery.data?.drafts ?? 0) + deviceDrafts.length || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
return (
|
return (
|
||||||
|
<Fragment key={f.view}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={f.view}
|
|
||||||
active={view === f.view}
|
active={view === f.view}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
title={collapsed ? t(f.label) : undefined}
|
title={collapsed ? t(f.label) : undefined}
|
||||||
@@ -828,6 +835,24 @@ export function MailLayout() {
|
|||||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
{/* Scheduled: shown while something waits to be sent (or is open). */}
|
||||||
|
{f.view === "folder:drafts" && (scheduled.length > 0 || isScheduled) && (
|
||||||
|
<SidebarItem
|
||||||
|
active={isScheduled}
|
||||||
|
collapsed={collapsed}
|
||||||
|
title={collapsed ? t("nav.scheduled") : undefined}
|
||||||
|
onClick={() => selectView("local:scheduled")}
|
||||||
|
>
|
||||||
|
<span className="flex w-[18px] justify-center">
|
||||||
|
<Clock size={18} />
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span className="flex-1 truncate">{t("nav.scheduled")}</span>}
|
||||||
|
{!collapsed && scheduled.length > 0 && (
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{scheduled.length}</span>
|
||||||
|
)}
|
||||||
|
</SidebarItem>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Per-account views are hidden entirely when collapsed. */}
|
{/* Per-account views are hidden entirely when collapsed. */}
|
||||||
@@ -912,8 +937,12 @@ export function MailLayout() {
|
|||||||
}`}
|
}`}
|
||||||
title={collapsed ? t("common.signOut") : undefined}
|
title={collapsed ? t("common.signOut") : undefined}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
// Anything still waiting in Scheduled goes out first: what is
|
||||||
|
// kept on this device is wiped with the session.
|
||||||
|
await flushLocal().catch(() => []);
|
||||||
await api.logout();
|
await api.logout();
|
||||||
await clearOfflineData();
|
await clearOfflineData();
|
||||||
|
await wipeLocal();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}}
|
}}
|
||||||
@@ -1036,7 +1065,7 @@ export function MailLayout() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isDrafts && (
|
{!isDrafts && !isScheduled && (
|
||||||
<div
|
<div
|
||||||
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
||||||
mobile ? "px-3" : "px-6"
|
mobile ? "px-3" : "px-6"
|
||||||
@@ -1156,14 +1185,23 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
{isDrafts && !term.trim() && !draftsQuery.data && isOfflineError(draftsQuery.error) ? (
|
{isScheduled ? (
|
||||||
<div className="p-6 text-[15px] text-app-faint">{t("offline.folderNotSaved")}</div>
|
<ScheduledList
|
||||||
|
messages={scheduled}
|
||||||
|
selectedId={paneDraft?.localId}
|
||||||
|
onOpen={(msg) => openDraftInPane(localToInit(msg))}
|
||||||
|
/>
|
||||||
) : isDrafts && !term.trim() ? (
|
) : isDrafts && !term.trim() ? (
|
||||||
<DraftsList
|
<DraftsList
|
||||||
drafts={draftsQuery.data ?? []}
|
drafts={draftsQuery.data ?? []}
|
||||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||||
selectedId={paneDraft?.draftId}
|
deviceDrafts={deviceDrafts}
|
||||||
|
note={
|
||||||
|
!draftsQuery.data && isOfflineError(draftsQuery.error) ? t("offline.draftsNotSaved") : undefined
|
||||||
|
}
|
||||||
|
selectedId={paneDraft?.localId ?? paneDraft?.draftId}
|
||||||
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
||||||
|
onOpenDevice={(msg) => openDraftInPane(localToInit(msg))}
|
||||||
onOpenRemote={async (remote) => {
|
onOpenRemote={async (remote) => {
|
||||||
// Adopt the server draft into an editable local draft, then open it.
|
// Adopt the server draft into an editable local draft, then open it.
|
||||||
const draft = await api.adoptDraft(remote.message_id);
|
const draft = await api.adoptDraft(remote.message_id);
|
||||||
@@ -1210,7 +1248,7 @@ export function MailLayout() {
|
|||||||
>
|
>
|
||||||
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
<RefreshIcon spinning={refreshing} size={mobile ? 22.5 : 15} />
|
||||||
</button>
|
</button>
|
||||||
{!isDrafts && total > 0 ? (
|
{!isDrafts && !isScheduled && total > 0 ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>
|
<span>
|
||||||
{pageStart + 1}–{pageEnd} of {total}
|
{pageStart + 1}–{pageEnd} of {total}
|
||||||
@@ -1242,7 +1280,7 @@ export function MailLayout() {
|
|||||||
title={t("nav.compose")}
|
title={t("nav.compose")}
|
||||||
aria-label={t("nav.compose")}
|
aria-label={t("nav.compose")}
|
||||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||||
className="wm-online-only absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
className="absolute bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||||
>
|
>
|
||||||
<Icon name="compose" size={22} />
|
<Icon name="compose" size={22} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1265,7 +1303,7 @@ export function MailLayout() {
|
|||||||
// Keyed by draft: picking a different one must build a fresh editor,
|
// Keyed by draft: picking a different one must build a fresh editor,
|
||||||
// because RichTextEditor seeds its body exactly once.
|
// because RichTextEditor seeds its body exactly once.
|
||||||
<Composer
|
<Composer
|
||||||
key={paneDraft.draftId}
|
key={paneDraft.localId ?? paneDraft.draftId}
|
||||||
init={paneDraft}
|
init={paneDraft}
|
||||||
onClose={closePaneDraft}
|
onClose={closePaneDraft}
|
||||||
fullWindow
|
fullWindow
|
||||||
|
|||||||
Reference in New Issue
Block a user