`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
240 lines
9.3 KiB
TypeScript
240 lines
9.3 KiB
TypeScript
import { create } from "zustand";
|
|
import { client, ref, setErrorMessage } from "@/jmap/client";
|
|
import type { EmailSubmission, GetResponse, Id, Mailbox, QueryResponse, SetResponse } from "@/jmap/types";
|
|
import { toast } from "@/ui/toast";
|
|
import { useMail } from "./mail";
|
|
import { canScheduleSend, maxDelayMs, SUBMISSION_CAP, type SubmissionCapability } from "@/lib/schedule";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
/**
|
|
* A held message lives in a folder of its own, the way Gmail's does, because
|
|
* the alternatives are both wrong: leaving it in Drafts invites the user to
|
|
* edit a message the queue has already frozen, and `onSuccessUpdateEmail`
|
|
* files it in Sent the instant the submission is created -- which for a
|
|
* scheduled send is a lie for however long the hold lasts.
|
|
*
|
|
* JMAP has no role for this (the IANA attribute registry has no `\Scheduled`),
|
|
* so it is an ordinary folder found by name.
|
|
*/
|
|
export const SCHEDULED_MAILBOX = "Scheduled";
|
|
|
|
/** What this account's server says about holding a message before sending it. */
|
|
export function submissionCapability(): SubmissionCapability | undefined {
|
|
const accountId = useMail.getState().accountId;
|
|
if (!accountId) return undefined;
|
|
return client.accountCapability<SubmissionCapability>(accountId, SUBMISSION_CAP);
|
|
}
|
|
|
|
/** Whether to offer scheduled send at all. */
|
|
export function scheduleSupported(): boolean {
|
|
return canScheduleSend(submissionCapability());
|
|
}
|
|
|
|
/** How far ahead this server will hold a message, in milliseconds. */
|
|
export function scheduleWindowMs(): number {
|
|
return maxDelayMs(submissionCapability());
|
|
}
|
|
|
|
/** Whether this folder is the one held messages wait in. */
|
|
export function isScheduledMailbox(m: Pick<Mailbox, "role" | "parentId" | "name">): boolean {
|
|
return !m.role && !m.parentId && m.name.toLowerCase() === SCHEDULED_MAILBOX.toLowerCase();
|
|
}
|
|
|
|
/** The Scheduled folder in a given set of mailboxes, if one exists yet. */
|
|
export function scheduledMailboxIdFrom(mailboxes: Record<Id, Mailbox>): Id | null {
|
|
return Object.values(mailboxes).find(isScheduledMailbox)?.id ?? null;
|
|
}
|
|
|
|
/** The Scheduled folder, if one exists yet. */
|
|
export function scheduledMailboxId(): Id | null {
|
|
return scheduledMailboxIdFrom(useMail.getState().mailboxes);
|
|
}
|
|
|
|
/** The Scheduled folder, creating it the first time something is scheduled. */
|
|
export async function ensureScheduledMailbox(): Promise<Id> {
|
|
const existing = scheduledMailboxId();
|
|
if (existing) return existing;
|
|
const accountId = useMail.getState().accountId!;
|
|
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", {
|
|
accountId,
|
|
create: { sched: { name: SCHEDULED_MAILBOX, parentId: null, isSubscribed: true } },
|
|
});
|
|
const err = res.notCreated?.sched;
|
|
// A racing tab (or another client) may have created it between the two calls.
|
|
if (err) {
|
|
await useMail.getState().loadMailboxes();
|
|
const again = scheduledMailboxId();
|
|
if (again) return again;
|
|
throw new Error(setErrorMessage(err));
|
|
}
|
|
const id = res.created!.sched!.id;
|
|
await useMail.getState().loadMailboxes();
|
|
return id;
|
|
}
|
|
|
|
export interface PendingSend {
|
|
id: Id;
|
|
emailId: Id;
|
|
/** Epoch milliseconds, as the server settled on it. */
|
|
sendAt: number;
|
|
undoStatus: EmailSubmission["undoStatus"];
|
|
}
|
|
|
|
interface ScheduledState {
|
|
/** Pending submissions, keyed by the message they will send. */
|
|
pending: Record<Id, PendingSend>;
|
|
loaded: boolean;
|
|
load(): Promise<void>;
|
|
cancel(emailId: Id): Promise<void>;
|
|
reconcile(): Promise<void>;
|
|
}
|
|
|
|
const SUB_PROPS = ["id", "emailId", "sendAt", "undoStatus"];
|
|
|
|
function toPending(s: EmailSubmission): PendingSend {
|
|
return { id: s.id, emailId: s.emailId, sendAt: Date.parse(s.sendAt), undoStatus: s.undoStatus };
|
|
}
|
|
|
|
/** Every submission still sitting in the server's queue. */
|
|
async function loadPending(accountId: Id): Promise<PendingSend[]> {
|
|
const res = await client.chain([
|
|
["EmailSubmission/query", { accountId, filter: { undoStatus: "pending" } }, "q"],
|
|
["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"],
|
|
]);
|
|
const got = res.get("g")?.[0] as unknown as GetResponse<EmailSubmission> | undefined;
|
|
return (got?.list ?? []).map(toPending);
|
|
}
|
|
|
|
/** The submissions belonging to a specific set of messages, whatever their status. */
|
|
async function loadFor(accountId: Id, emailIds: Id[]): Promise<PendingSend[]> {
|
|
if (!emailIds.length) return [];
|
|
const res = await client.chain([
|
|
["EmailSubmission/query", { accountId, filter: { emailIds } }, "q"],
|
|
["EmailSubmission/get", { accountId, "#ids": ref("q", "EmailSubmission/query", "/ids"), properties: SUB_PROPS }, "g"],
|
|
]);
|
|
const got = res.get("g")?.[0] as unknown as GetResponse<EmailSubmission> | undefined;
|
|
return (got?.list ?? []).map(toPending);
|
|
}
|
|
|
|
export const useScheduled = create<ScheduledState>((set, get) => ({
|
|
pending: {},
|
|
loaded: false,
|
|
|
|
async load() {
|
|
const accountId = useMail.getState().accountId;
|
|
if (!accountId) return;
|
|
try {
|
|
const list = await loadPending(accountId);
|
|
const pending: Record<Id, PendingSend> = {};
|
|
for (const s of list) pending[s.emailId] = s;
|
|
set({ pending, loaded: true });
|
|
} catch {
|
|
// A server without the submission capability simply has nothing to show.
|
|
set({ loaded: true });
|
|
}
|
|
},
|
|
|
|
async cancel(emailId) {
|
|
const accountId = useMail.getState().accountId!;
|
|
const sub = get().pending[emailId];
|
|
if (!sub) throw new Error("This message is no longer waiting to be sent");
|
|
const draftsId = useMail.getState().roleId("drafts");
|
|
const scheduledId = scheduledMailboxId();
|
|
// Not `onSuccessUpdateEmail`: RFC 8621 keys it by submission id, but
|
|
// Stalwart takes a plain key as an Email id and would patch the wrong
|
|
// object. Moving the message back is a separate call in the same request.
|
|
const res = await client.chain([
|
|
["EmailSubmission/set", { accountId, update: { [sub.id]: { undoStatus: "canceled" } } }, "s"],
|
|
[
|
|
"Email/set",
|
|
{
|
|
accountId,
|
|
update: {
|
|
[emailId]: {
|
|
"keywords/$draft": true,
|
|
...(draftsId ? { [`mailboxIds/${draftsId}`]: true } : {}),
|
|
...(scheduledId ? { [`mailboxIds/${scheduledId}`]: null } : {}),
|
|
},
|
|
},
|
|
},
|
|
"e",
|
|
],
|
|
], { allowErrors: true });
|
|
const setRes = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
|
|
if (setRes.__error) throw new Error(setErrorMessage(setRes.__error));
|
|
const err = setRes.notUpdated?.[sub.id];
|
|
if (err) throw new Error(setErrorMessage(err));
|
|
set((s) => {
|
|
const { [emailId]: _drop, ...rest } = s.pending;
|
|
return { pending: rest };
|
|
});
|
|
const mail = useMail.getState();
|
|
void mail.loadMailboxes();
|
|
void mail.refreshList();
|
|
},
|
|
|
|
/**
|
|
* Nothing moves a message out of Scheduled when its hold expires -- the
|
|
* server sends it and updates the submission, but the message stays where we
|
|
* filed it. So on the way into the folder, settle up: what went out belongs
|
|
* in Sent, what was cancelled elsewhere belongs back in Drafts.
|
|
*/
|
|
async reconcile() {
|
|
const mail = useMail.getState();
|
|
const accountId = mail.accountId;
|
|
const scheduledId = scheduledMailboxId();
|
|
if (!accountId || !scheduledId) return;
|
|
try {
|
|
const q = await client.call<QueryResponse>("Email/query", {
|
|
accountId,
|
|
filter: { inMailbox: scheduledId },
|
|
limit: 200,
|
|
});
|
|
if (!q.ids.length) {
|
|
set({ pending: {} });
|
|
return;
|
|
}
|
|
const subs = await loadFor(accountId, q.ids);
|
|
// A message may carry several submissions if it was rescheduled. One
|
|
// still pending settles it whatever the timestamps say -- the queue holds
|
|
// a copy either way -- and otherwise the most recent wins.
|
|
const latest = new Map<Id, PendingSend>();
|
|
for (const s of subs) {
|
|
const prev = latest.get(s.emailId);
|
|
if (!prev) { latest.set(s.emailId, s); continue; }
|
|
if (prev.undoStatus === "pending") continue;
|
|
if (s.undoStatus === "pending" || s.sendAt >= prev.sendAt) latest.set(s.emailId, s);
|
|
}
|
|
const sentId = mail.roleId("sent");
|
|
const draftsId = mail.roleId("drafts");
|
|
const update: Record<Id, Record<string, unknown>> = {};
|
|
const pending: Record<Id, PendingSend> = {};
|
|
for (const emailId of q.ids) {
|
|
const s = latest.get(emailId);
|
|
if (s?.undoStatus === "pending") {
|
|
pending[emailId] = s;
|
|
continue;
|
|
}
|
|
// Cancelled goes back to Drafts; sent (or a submission the server no
|
|
// longer knows about) goes to Sent, which is where it actually is.
|
|
const toDrafts = s?.undoStatus === "canceled";
|
|
const dest = toDrafts ? draftsId : sentId;
|
|
if (!dest) continue;
|
|
update[emailId] = {
|
|
[`mailboxIds/${scheduledId}`]: null,
|
|
[`mailboxIds/${dest}`]: true,
|
|
...(toDrafts ? { "keywords/$draft": true } : {}),
|
|
};
|
|
}
|
|
set({ pending, loaded: true });
|
|
if (Object.keys(update).length) {
|
|
await client.call("Email/set", { accountId, update });
|
|
void mail.loadMailboxes();
|
|
void mail.refreshList();
|
|
}
|
|
} catch (err) {
|
|
toast.error(t("Could not update the Scheduled folder: {error}", { error: (err as Error).message }));
|
|
}
|
|
},
|
|
}));
|