Merge branch 'main' into feat/forward-as-eml

# Conflicts:
#	web/src/store/compose.ts
#	web/src/views/mail/MessageView.tsx
This commit is contained in:
2026-09-01 22:31:42 -07:00
19 changed files with 1289 additions and 6 deletions
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
/**
* The dates below are written as local-time strings on purpose. The segments
* follow the reader's timezone, so a test pinned to UTC instants would pass or
* fail depending on where it ran.
*/
describe("archiveSegments", () => {
it("gives the year, and the zero-padded month", () => {
expect(archiveSegments("2026-09-04T10:00:00", "year")).toEqual(["2026"]);
expect(archiveSegments("2026-09-04T10:00:00", "month")).toEqual(["2026", "09"]);
});
it("zero-pads every month below October, so the folders sort", () => {
expect(archiveSegments("2026-01-15T10:00:00", "month")).toEqual(["2026", "01"]);
expect(archiveSegments("2026-10-15T10:00:00", "month")).toEqual(["2026", "10"]);
expect(archiveSegments("2026-12-15T10:00:00", "month")).toEqual(["2026", "12"]);
});
it("returns nothing to append when the date cannot be read", () => {
// Archive itself, rather than a folder named after a guess.
expect(archiveSegments(null, "month")).toEqual([]);
expect(archiveSegments(undefined, "month")).toEqual([]);
expect(archiveSegments("", "month")).toEqual([]);
expect(archiveSegments("not a date", "month")).toEqual([]);
});
it("joins to a path", () => {
expect(archivePath(["2026", "09"])).toBe("2026/09");
expect(archivePath([])).toBe("");
});
});
describe("groupByArchivePath", () => {
it("keeps one destination for a selection from one month", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-09-28T10:00:00" },
],
"month",
);
expect(groups).toHaveLength(1);
expect(groups[0]!.segments).toEqual(["2026", "09"]);
expect(groups[0]!.ids).toEqual(["a", "b"]);
});
it("splits a selection that spans months, which is the case that matters", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-08-30T10:00:00" },
{ id: "c", receivedAt: "2026-09-01T10:00:00" },
],
"month",
);
expect(groups.map((g) => g.segments)).toEqual([
["2026", "09"],
["2026", "08"],
]);
expect(groups[0]!.ids).toEqual(["a", "c"]);
expect(groups[1]!.ids).toEqual(["b"]);
});
it("collapses the same span back to one group at year granularity", () => {
const entries = [
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: "2026-02-28T10:00:00" },
];
expect(groupByArchivePath(entries, "month")).toHaveLength(2);
expect(groupByArchivePath(entries, "year")).toHaveLength(1);
});
it("orders groups by where their first message appeared", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2024-01-04T10:00:00" },
{ id: "b", receivedAt: "2026-01-04T10:00:00" },
],
"year",
);
expect(groups.map((g) => archivePath(g.segments))).toEqual(["2024", "2026"]);
});
it("gathers the undatable ones into their own group, bound for Archive itself", () => {
const groups = groupByArchivePath(
[
{ id: "a", receivedAt: "2026-09-04T10:00:00" },
{ id: "b", receivedAt: null },
{ id: "c", receivedAt: "bad" },
],
"month",
);
expect(groups).toHaveLength(2);
expect(groups[1]!.segments).toEqual([]);
expect(groups[1]!.ids).toEqual(["b", "c"]);
});
it("has nothing to do with an empty selection", () => {
expect(groupByArchivePath([], "month")).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { spamReport } from "@/lib/spamScore";
const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v });
const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v });
describe("spamReport, SpamAssassin-shaped headers", () => {
it("reads the verdict, score, threshold and tests", () => {
const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no");
expect(r).not.toBeNull();
expect(r!.verdict).toBe("spam");
expect(r!.score).toBe(6.7);
expect(r!.threshold).toBe(5);
expect(r!.source).toBe("spamassassin");
// Biggest mover first, so the reason it was scored reads off the top.
expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]);
});
it("reads a negative score and a clean verdict", () => {
const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(-2.6);
expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 });
});
it("survives a folded header, which is how they arrive", () => {
const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]");
expect(r!.score).toBe(6.7);
expect(r!.rules).toHaveLength(2);
});
it("keeps a verdict that states no score, and a score that states no verdict", () => {
expect(sa("Yes")!.verdict).toBe("spam");
expect(sa("Yes")!.score).toBeNull();
const scoreOnly = sa("score=1.2 required=5.0");
expect(scoreOnly!.verdict).toBeNull();
expect(scoreOnly!.score).toBe(1.2);
});
it("says nothing when there is nothing it understands", () => {
expect(sa("")).toBeNull();
expect(sa("something else entirely")).toBeNull();
expect(spamReport({})).toBeNull();
});
it("drops a malformed test rather than scoring it as zero", () => {
const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]");
expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]);
});
});
describe("spamReport, Rspamd", () => {
it("reads the action, score, threshold and rules with their notes", () => {
const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(1.2);
expect(r!.threshold).toBe(15);
expect(r!.source).toBe("rspamd");
expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 });
expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com");
// An empty bracket is not a note.
expect(r!.rules[0]!.detail).toBeUndefined();
});
it("treats the acting verdicts as spam and False as clean", () => {
expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean");
});
it("declines to call greylisting a verdict about the message", () => {
const r = rs("default: greylist [8.00 / 15.00];");
expect(r!.verdict).toBeNull();
expect(r!.score).toBe(8);
});
it("says nothing for a header it cannot read", () => {
expect(rs("")).toBeNull();
expect(rs("default: False")).toBeNull();
});
});
describe("spamReport, precedence and fallback", () => {
it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => {
const r = spamReport({
"header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0",
"header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];",
});
expect(r!.source).toBe("spamassassin");
expect(r!.verdict).toBe("spam");
});
it("falls back to a bare score, with no threshold to read it against", () => {
const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" });
expect(r!.score).toBe(4.1);
expect(r!.threshold).toBeNull();
expect(r!.verdict).toBeNull();
expect(r!.rules).toEqual([]);
});
it("does not invent a verdict from score against threshold", () => {
// Above the threshold, but the filter did not say "Yes" -- so neither do we.
const r = sa("score=9.9 required=5.0 tests=[X=9.9]");
expect(r!.verdict).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
describe("navSwipeThreshold", () => {
it("asks for more travel than a row swipe does, at every width", () => {
// Not because the consequence is bigger -- stepping back undoes it -- but
// because this gesture reveals nothing on the way and offers no Undo
// after, so the distance is the only chance to not mean it.
for (const width of [320, 360, 414, 768, 1024]) {
expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width));
}
});
it("is a share of the width, bounded at both ends", () => {
expect(navSwipeThreshold(360)).toBe(108);
expect(navSwipeThreshold(200)).toBe(80); // floor
expect(navSwipeThreshold(1000)).toBe(180); // ceiling
});
});
describe("swipeNavDirection", () => {
const W = 400; // threshold is 120 at this width
it("goes forward when the finger drags left, the way pages turn", () => {
expect(swipeNavDirection(-200, W)).toBe(1);
});
it("goes back when the finger drags right", () => {
expect(swipeNavDirection(200, W)).toBe(-1);
});
it("does nothing short of the threshold, in either direction", () => {
expect(swipeNavDirection(-60, W)).toBe(0);
expect(swipeNavDirection(60, W)).toBe(0);
expect(swipeNavDirection(0, W)).toBe(0);
});
it("fires exactly at the threshold and not a pixel before", () => {
const at = navSwipeThreshold(W);
expect(swipeNavDirection(-at, W)).toBe(1);
expect(swipeNavDirection(-(at - 1), W)).toBe(0);
expect(swipeNavDirection(at, W)).toBe(-1);
expect(swipeNavDirection(at - 1, W)).toBe(0);
});
it("scales with the width, so a tablet asks for more than a phone", () => {
// The same 120px drag commits on a narrow screen and does not on a wide one.
expect(swipeNavDirection(-120, 360)).toBe(1);
expect(swipeNavDirection(-120, 1024)).toBe(0);
});
});
describe("the axis lock this shares with the row swipe", () => {
it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => {
// The day view scrolls through the hours; a scroll misread as a swipe
// throws the reader into another day.
expect(lockAxis(20, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("commits to sideways only when it is clearly sideways", () => {
expect(lockAxis(40, 10)).toBe("x");
});
it("is undecided until the drag has moved at all", () => {
expect(lockAxis(2, 2)).toBeNull();
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { fillPlaceholders, PLACEHOLDER_NAMES, type PlaceholderContext } from "@/lib/templatePlaceholders";
const AT = new Date("2026-03-04T15:07:00Z");
function ctx(over: Partial<PlaceholderContext> = {}): PlaceholderContext {
return {
to: [{ name: "Ada Lovelace", email: "[email protected]" }],
from: { name: "Grace Hopper", email: "[email protected]" },
subject: "Quarterly report",
now: AT,
...over,
};
}
describe("fillPlaceholders", () => {
it("fills the names it knows", () => {
expect(fillPlaceholders("Hi {{recipientFirstName}},", ctx(), { html: true })).toBe("Hi Ada,");
expect(fillPlaceholders("{{recipientName}} <{{recipientEmail}}>", ctx(), { html: false })).toBe("Ada Lovelace <[email protected]>");
expect(fillPlaceholders("-- {{myName}}", ctx(), { html: true })).toBe("-- Grace Hopper");
expect(fillPlaceholders("Re: {{subject}}", ctx(), { html: false })).toBe("Re: Quarterly report");
});
it("tolerates spaces inside the braces but not a different case", () => {
expect(fillPlaceholders("{{ myEmail }}", ctx(), { html: false })).toBe("[email protected]");
expect(fillPlaceholders("{{MyEmail}}", ctx(), { html: false })).toBe("{{MyEmail}}");
});
it("leaves a placeholder it cannot answer exactly as written", () => {
// The case the design is about: a template inserted before the message is
// addressed. "Hi ," would be wrong; "Hi {{recipientFirstName}}," is unfinished.
const unaddressed = ctx({ to: [] });
expect(fillPlaceholders("Hi {{recipientFirstName}},", unaddressed, { html: true })).toBe("Hi {{recipientFirstName}},");
expect(fillPlaceholders("{{recipientEmail}}", unaddressed, { html: false })).toBe("{{recipientEmail}}");
expect(fillPlaceholders("{{myName}}", ctx({ from: null }), { html: false })).toBe("{{myName}}");
});
it("leaves a name it does not know alone rather than eating it", () => {
expect(fillPlaceholders("{{nonsense}} {{}} {{ }}", ctx(), { html: true })).toBe("{{nonsense}} {{}} {{ }}");
});
it("falls back to the local part when a recipient has no name", () => {
const c = ctx({ to: [{ name: null, email: "[email protected]" }] });
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toBe("ada.lovelace");
expect(fillPlaceholders("{{recipientFirstName}}", c, { html: false })).toBe("ada.lovelace");
});
it("escapes a substituted value on the way into HTML, and not into a subject", () => {
const c = ctx({ to: [{ name: 'Ada <script>alert("x")</script>', email: "[email protected]" }] });
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).not.toContain("<script>");
expect(fillPlaceholders("{{recipientName}}", c, { html: true })).toContain("&lt;script&gt;");
expect(fillPlaceholders("{{recipientName}}", c, { html: false })).toContain("<script>");
});
it("repeats a placeholder as many times as it appears", () => {
expect(fillPlaceholders("{{recipientFirstName}} {{recipientFirstName}}", ctx(), { html: true })).toBe("Ada Ada");
});
it("answers date and time from the injected clock", () => {
const date = fillPlaceholders("{{date}}", ctx(), { html: false });
const time = fillPlaceholders("{{time}}", ctx(), { html: false });
expect(date).not.toBe("{{date}}");
expect(date).toMatch(/2026/);
expect(time).not.toBe("{{time}}");
expect(time).toMatch(/\d/);
});
it("names every resolver in the list Settings shows", () => {
expect(PLACEHOLDER_NAMES).toEqual([
"recipientName",
"recipientFirstName",
"recipientEmail",
"myName",
"myEmail",
"subject",
"date",
"time",
]);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Where a message goes when it is archived by date.
*
* The folders are **numeric and zero-padded** -- `Archive/2026`,
* `Archive/2026/09` -- and deliberately not month names. Two reasons, both
* about the fact that these are real server-side mailboxes rather than
* anything of ihasmail's:
*
* - Every other client sees them. A folder created as "September" by someone
* reading in English stays "September" for the same account read in
* Japanese, because the name is stored, not translated. A number reads the
* same in every language ihasmail ships.
* - They sort. `09` sits between `08` and `10` in any folder list; "September"
* sits between "October" and nothing useful.
*
* The date is read in the reader's own timezone rather than UTC, because it has
* to agree with the date shown against the message in the list. A message that
* arrived at 00:30 UTC on 1 September is dated 31 August in New York, and
* filing it under `09` while the list says August would be the app disagreeing
* with itself.
*/
export type ArchiveGranularity = "year" | "month";
/**
* Path segments below the Archive folder. Empty means "no dated subfolder" --
* a message whose date cannot be read belongs in Archive itself rather than in
* a folder named after a guess.
*/
export function archiveSegments(when: string | null | undefined, granularity: ArchiveGranularity): string[] {
if (!when) return [];
const d = new Date(when);
if (Number.isNaN(d.getTime())) return [];
const year = String(d.getFullYear());
if (granularity === "year") return [year];
return [year, String(d.getMonth() + 1).padStart(2, "0")];
}
/** The segments as one string, for grouping and for naming the destination. */
export function archivePath(segments: string[]): string {
return segments.join("/");
}
export interface ArchiveGroup {
segments: string[];
ids: string[];
}
/**
* Split a selection by where each message is going.
*
* Archiving by month across a selection spanning two months is two
* destinations, not one, so this is the shape the caller needs -- and the
* reason the action cannot simply resolve one folder up front. Groups come
* back in the order their first message appeared, so the toast that follows
* names them in the order the reader was looking at.
*/
export function groupByArchivePath(
entries: Array<{ id: string; receivedAt?: string | null }>,
granularity: ArchiveGranularity,
): ArchiveGroup[] {
const groups = new Map<string, ArchiveGroup>();
for (const e of entries) {
const segments = archiveSegments(e.receivedAt, granularity);
const key = archivePath(segments);
const existing = groups.get(key);
if (existing) existing.ids.push(e.id);
else groups.set(key, { segments, ids: [e.id] });
}
return [...groups.values()];
}
+146
View File
@@ -0,0 +1,146 @@
/**
* What the spam filter in front of the mailbox already said, read back off the
* message.
*
* Nothing here scores anything. The server has already done that at delivery
* and written its working into headers; this only reads them, which is why it
* costs nothing and cannot disagree with the filter that actually made the
* decision.
*
* Two formats cover what sits in front of a Stalwart mailbox in practice: the
* SpamAssassin-shaped `X-Spam-*` set, which Stalwart's own filter writes, and
* Rspamd's `X-Spamd-Result`. Anything else is left unread rather than guessed
* at -- a misparsed score shown confidently is worse than no panel.
*
* The one editorial decision: **a score is not shown without its threshold
* where the header states one.** 6.7 is damning against a threshold of 5 and
* unremarkable against 15, so the number alone is not something a reader can
* act on. Where no threshold is stated, that is said rather than assumed.
*/
export interface SpamRule {
/** The rule's own name, as the filter wrote it. */
name: string;
/** What it contributed. Negative moves the message towards clean. */
score: number;
/** Rspamd's bracketed note, where there is one. */
detail?: string;
}
export interface SpamReport {
/**
* What the filter concluded, and only where it said so outright. Left `null`
* rather than derived from score against threshold: the filter applies
* policy we cannot see, and inventing a verdict it did not state would be
* putting words in its mouth.
*/
verdict: "spam" | "clean" | null;
score: number | null;
threshold: number | null;
/** Biggest movers first; ties keep the order the filter listed them in. */
rules: SpamRule[];
source: "spamassassin" | "rspamd";
}
/** Headers requested for a full message; kept beside the parser that reads them. */
export const SPAM_HEADER_PROPS = [
"header:X-Spam-Status:asText",
"header:X-Spam-Score:asText",
"header:X-Spamd-Result:asText",
] as const;
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
function flatten(v: string | null | undefined): string {
return (v ?? "").replace(/\s+/g, " ").trim();
}
function num(raw: string | undefined): number | null {
if (raw === undefined) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/** Biggest absolute contribution first: what moved the message, in order. */
function byWeight(rules: SpamRule[]): SpamRule[] {
return [...rules].sort((a, b) => Math.abs(b.score) - Math.abs(a.score));
}
/**
* `Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001]`
* The verdict word is the only part guaranteed to be there.
*/
function parseSpamAssassin(raw: string): SpamReport | null {
const s = flatten(raw);
if (!s) return null;
const verdictWord = /^(yes|no)\b/i.exec(s);
const score = num(/\bscore=(-?[\d.]+)/i.exec(s)?.[1]);
const threshold = num(/\b(?:required|require)=(-?[\d.]+)/i.exec(s)?.[1]);
// Nothing usable: not a header we understand, so say so by returning null
// rather than rendering an empty panel.
if (!verdictWord && score === null) return null;
const rules: SpamRule[] = [];
const tests = /\btests=\[([^\]]*)\]/i.exec(s)?.[1];
if (tests) {
for (const part of tests.split(",")) {
const m = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(-?[\d.]+)\s*$/.exec(part);
const value = num(m?.[2]);
if (m?.[1] && value !== null) rules.push({ name: m[1], score: value });
}
}
return {
verdict: verdictWord ? (verdictWord[1]!.toLowerCase() === "yes" ? "spam" : "clean") : null,
score,
threshold,
rules: byWeight(rules),
source: "spamassassin",
};
}
/**
* `default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[]`
* The action word before the brackets is the verdict; `False` is not spam.
*/
function parseRspamd(raw: string): SpamReport | null {
const s = flatten(raw);
if (!s) return null;
const head = /^[^:]*:\s*(\S+)\s*\[\s*(-?[\d.]+)\s*\/\s*(-?[\d.]+)\s*\]/.exec(s);
if (!head) return null;
const action = head[1]!.toLowerCase();
const rules: SpamRule[] = [];
// Each rule after the head, `NAME(score)` with an optional bracketed note.
for (const m of s.matchAll(/([A-Z][A-Z0-9_]*)\(\s*(-?[\d.]+)\s*\)(?:\[([^\]]*)\])?/g)) {
const value = num(m[2]);
if (value === null) continue;
const detail = m[3]?.trim();
rules.push(detail ? { name: m[1]!, score: value, detail } : { name: m[1]!, score: value });
}
return {
// "False" is Rspamd saying the message is not spam; "True", and the named
// actions that reject or bin it, are it saying the opposite. Anything else
// -- greylisting, for one -- is not a verdict about the message.
verdict: action === "false" ? "clean" : action === "true" || action === "reject" || action === "add_header" || action === "rewrite_subject" ? "spam" : null,
score: num(head[2]),
threshold: num(head[3]),
rules: byWeight(rules),
source: "rspamd",
};
}
type HeaderBag = Partial<Record<(typeof SPAM_HEADER_PROPS)[number], string | null>>;
/**
* Read whichever of the two the message carries, preferring the SpamAssassin
* set because it is what Stalwart's own filter writes; a message that has been
* through both keeps the nearer verdict.
*/
export function spamReport(e: HeaderBag): SpamReport | null {
const sa = parseSpamAssassin(e["header:X-Spam-Status:asText"] ?? "");
if (sa) return sa;
const rspamd = parseRspamd(e["header:X-Spamd-Result:asText"] ?? "");
if (rspamd) return rspamd;
// Last resort: a bare score with nothing to read it against. Worth showing,
// because the alternative is hiding the only thing the filter said.
const bare = num(flatten(e["header:X-Spam-Score:asText"]).replace(/^\+/, "") || undefined);
if (bare === null) return null;
return { verdict: null, score: bare, threshold: null, rules: [], source: "spamassassin" };
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Placeholders in templates, filled at the moment one is inserted.
*
* Two rules decide the whole design:
*
* - **An unresolved placeholder is left exactly as written.** A template
* inserted before the message is addressed cannot know who it is going to,
* and substituting an empty string there produces "Hi ," -- a greeting that
* is wrong rather than unfinished. Leaving `{{recipientName}}` in the body
* says which word is still missing, and it can be typed over. It is also
* what makes inserting a template early a valid thing to do rather than a
* mistake to undo.
* - **A name that is not a placeholder is left alone too.** Templates are
* written by hand and `{{` is not reserved anywhere else, but a body that
* silently ate an unrecognised token would be worse than one that shows it.
*
* Dates and times go through `datetime.ts` rather than `toLocaleDateString`,
* so a template follows the same date order and clock the rest of the app was
* told to use.
*/
import { escapeHtml } from "./text";
import { formatDate, formatClock } from "./datetime";
import type { EmailAddress } from "@/jmap/types";
export interface PlaceholderContext {
/** Where the message is addressed, in order; the first is what the singular names refer to. */
to: EmailAddress[];
/** The identity the draft is sending as. */
from: { name?: string | null; email?: string | null } | null;
subject: string;
/** Injectable so tests do not depend on the clock. */
now?: Date;
}
/**
* What each name resolves to, in the order they are shown in Settings.
* `null` from a resolver means "cannot be answered yet", which is the case
* the rule above is about -- distinct from an empty string, which is an answer.
*/
const RESOLVERS: Record<string, (c: PlaceholderContext) => string | null> = {
recipientName: (c) => personalName(c.to[0]),
recipientFirstName: (c) => {
const n = personalName(c.to[0]);
return n ? (n.split(/\s+/)[0] ?? null) : null;
},
recipientEmail: (c) => c.to[0]?.email || null,
myName: (c) => c.from?.name?.trim() || null,
myEmail: (c) => c.from?.email || null,
subject: (c) => c.subject || null,
date: (c) => formatDate(c.now ?? new Date()),
time: (c) => formatClock(c.now ?? new Date()),
};
/** The names, for the list shown under the template editor. */
export const PLACEHOLDER_NAMES = Object.keys(RESOLVERS);
/**
* A recipient's human name: what they are called if we know it, otherwise the
* local part, which for `firstname.lastname@` is still better than the whole
* address in the middle of a sentence. Never the domain.
*/
function personalName(a: EmailAddress | undefined): string | null {
if (!a) return null;
const name = a.name?.trim();
if (name) return name;
const local = (a.email ?? "").split("@")[0] ?? "";
return local || null;
}
/**
* `{{ name }}` tolerates the spaces; the name itself is matched exactly,
* because `{{Date}}` meaning `{{date}}` would make the list in Settings a
* suggestion rather than the set.
*/
const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g;
/**
* Fill `input`, escaping substituted values when the destination is HTML.
* Escaping happens here rather than at the call site because the values come
* from contact cards and typed addresses -- a display name is not trusted
* markup, and the body it lands in is inserted as HTML.
*/
export function fillPlaceholders(input: string, ctx: PlaceholderContext, opts: { html: boolean }): string {
return input.replace(TOKEN, (whole, name: string) => {
const resolver = RESOLVERS[name];
if (!resolver) return whole;
const value = resolver(ctx);
if (value === null) return whole;
return opts.html ? escapeHtml(value) : value;
});
}
+126
View File
@@ -490,3 +490,129 @@ export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled:
};
}, [el, enabled]);
}
/**
* How far a horizontal drag must travel before it moves the calendar to
* another day or month.
*
* Further than a row swipe, and not because the consequence is bigger --
* stepping a calendar is undone by stepping back, while a swiped row has
* already been archived. It is because this gesture has no way to change its
* mind. A row slides open as it goes, so the strip underneath names what is
* about to happen and letting go early calls it off, and a toast offers Undo
* afterwards. Stepping the calendar shows nothing on the way and offers
* nothing after, so the distance is the only chance to not mean it.
*/
export function navSwipeThreshold(width: number): number {
return Math.max(80, Math.min(180, width * 0.3));
}
/**
* Which way a finished drag sends the view: -1 back, +1 forward, 0 nowhere.
*
* Dragging left pulls the next period in from the right, which is how paper,
* phones and every other calendar behave. (It would need mirroring for a
* right-to-left interface; there is not one yet, and the day there is, this is
* one of the places that has to know.)
*/
export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
const threshold = navSwipeThreshold(width);
if (dx <= -threshold) return 1;
if (dx >= threshold) return -1;
return 0;
}
/**
* Swipe sideways across a calendar to step it a period at a time.
*
* Three things it deliberately does not do:
*
* - **No visual drag.** The row swipe slides the row open because the strip
* underneath has to name which of six actions is about to happen. Stepping
* a calendar has two outcomes and the direction of the finger already says
* which, so there is nothing to reveal -- and translating the grid would
* break the sticky day header, since a transform makes a containing block.
* The threshold is reported by the vibration motor instead, which is what
* the haptics are for: a swipe fires as the finger passes a line it cannot
* see.
* - **It does not start on an event.** A drag beginning on an event chip is
* left alone, so that moving an event by dragging it stays available to be
* built without having to be untangled from this first. Which gesture is
* meant is decidable at the moment the finger lands, and that is the only
* moment it can be decided cleanly.
* - **It does not start on the toolbar.** Buttons live there.
*
* The axis lock is the shared one, so it keeps the same bias towards the
* vertical: the day grid scrolls through the hours, and a scroll misread as a
* swipe throws the reader into another day.
*/
export function useSwipeNav(
el: HTMLElement | null,
opts: { onStep: (n: -1 | 1) => void; enabled: boolean; ignore?: string },
) {
const step = useRef(opts.onStep);
step.current = opts.onStep;
const { enabled, ignore } = opts;
useEffect(() => {
if (!el || !enabled) return;
let startX: number | null = null;
let startY = 0;
let axis: Axis = null;
let fired = false;
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if (ignore && (t.target as Element | null)?.closest?.(ignore)) return;
startX = t.clientX;
startY = t.clientY;
axis = null;
fired = false;
};
const onMove = (e: TouchEvent) => {
if (startX === null || e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!axis) {
axis = lockAxis(dx, dy);
// Committed to scrolling: stay out of the way for the rest of the drag.
if (axis === "y") startX = null;
return;
}
if (axis !== "x") return;
// Once sideways, the browser must not also scroll.
if (e.cancelable) e.preventDefault();
if (!fired && swipeNavDirection(dx, el.clientWidth || window.innerWidth) !== 0) {
fired = true;
haptic();
}
};
const onEnd = (e: TouchEvent) => {
if (startX === null) return;
const t = e.changedTouches[0];
const dx = t ? t.clientX - startX : 0;
const wasX = axis === "x";
startX = null;
axis = null;
fired = false;
if (!wasX) return;
const dir = swipeNavDirection(dx, el.clientWidth || window.innerWidth);
if (dir !== 0) step.current(dir);
};
el.addEventListener("touchstart", onStart, { passive: true });
el.addEventListener("touchmove", onMove, { passive: false });
el.addEventListener("touchend", onEnd);
el.addEventListener("touchcancel", onEnd);
return () => {
el.removeEventListener("touchstart", onStart);
el.removeEventListener("touchmove", onMove);
el.removeEventListener("touchend", onEnd);
el.removeEventListener("touchcancel", onEnd);
};
}, [el, enabled, ignore]);
}