Merge branch 'main' into feat/spam-score-panel
# Conflicts: # web/src/store/mail.ts # web/src/styles/app.css
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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("<script>");
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { JmapSession, Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Archiving into a dated subfolder. The parts worth testing are the ones that
|
||||
* touch the server: the folders get created once and reused after that, and a
|
||||
* selection spanning two months becomes two moves rather than one.
|
||||
*/
|
||||
|
||||
const ARCHIVE = "mbArchive";
|
||||
|
||||
interface Created {
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
/** A server that holds a mailbox tree and records what was created and moved. */
|
||||
function server(initial: Array<Partial<Mailbox> & { id: string; name: string }> = []) {
|
||||
const boxes = new Map<string, Partial<Mailbox> & { id: string; name: string }>();
|
||||
boxes.set(ARCHIVE, { id: ARCHIVE, role: "archive", name: "Archive", parentId: null });
|
||||
for (const b of initial) boxes.set(b.id, b);
|
||||
|
||||
const created: Created[] = [];
|
||||
const moves: Array<{ id: string; to: string }> = [];
|
||||
let counter = 0;
|
||||
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Mailbox/set" && args.create) {
|
||||
const spec = (args.create as Record<string, { name: string; parentId: string | null }>).n!;
|
||||
const newId = `mb-new-${++counter}`;
|
||||
created.push({ name: spec.name, parentId: spec.parentId });
|
||||
boxes.set(newId, { id: newId, name: spec.name, parentId: spec.parentId, role: null });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { n: { id: newId } }, notCreated: {} }, id];
|
||||
}
|
||||
if (name === "Mailbox/get") {
|
||||
return [name, { accountId: "a1", state: "1", list: [...boxes.values()], notFound: [] }, id];
|
||||
}
|
||||
if (name === "Email/set" && args.update) {
|
||||
for (const [emailId, patch] of Object.entries(args.update as Record<string, { mailboxIds?: Record<string, boolean> }>)) {
|
||||
const to = Object.keys(patch.mailboxIds ?? {})[0];
|
||||
if (to) moves.push({ id: emailId, to });
|
||||
}
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", updated: {}, notUpdated: {} }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { created, moves, boxes };
|
||||
}
|
||||
|
||||
const messages = () => useToasts.getState().toasts.map((t) => t.message);
|
||||
|
||||
/** Two messages from September, one from August, all local time. */
|
||||
function seed() {
|
||||
useMail.setState({
|
||||
emails: {
|
||||
e1: { id: "e1", receivedAt: "2026-09-04T10:00:00", mailboxIds: { mbInbox: true } },
|
||||
e2: { id: "e2", receivedAt: "2026-09-28T10:00:00", mailboxIds: { mbInbox: true } },
|
||||
e3: { id: "e3", receivedAt: "2026-08-30T10:00:00", mailboxIds: { mbInbox: true } },
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {} },
|
||||
accounts: {},
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
mailboxes: { [ARCHIVE]: { id: ARCHIVE, role: "archive", name: "Archive", parentId: null } } as never,
|
||||
list: null,
|
||||
emails: {},
|
||||
selected: {},
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
seed();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("archiveByDate", () => {
|
||||
it("creates the year folder under Archive and files into it", async () => {
|
||||
const s = server();
|
||||
await useMail.getState().archiveByDate(["e1"], "year");
|
||||
expect(s.created).toEqual([{ name: "2026", parentId: ARCHIVE }]);
|
||||
expect(s.moves).toEqual([{ id: "e1", to: "mb-new-1" }]);
|
||||
});
|
||||
|
||||
it("creates year then month, nesting the month inside the year", async () => {
|
||||
const s = server();
|
||||
await useMail.getState().archiveByDate(["e1"], "month");
|
||||
expect(s.created).toEqual([
|
||||
{ name: "2026", parentId: ARCHIVE },
|
||||
{ name: "09", parentId: "mb-new-1" },
|
||||
]);
|
||||
expect(s.moves).toEqual([{ id: "e1", to: "mb-new-2" }]);
|
||||
});
|
||||
|
||||
it("reuses a folder that already exists rather than making a second one", async () => {
|
||||
const s = server([
|
||||
{ id: "mb2026", name: "2026", parentId: ARCHIVE, role: null },
|
||||
{ id: "mb09", name: "09", parentId: "mb2026", role: null },
|
||||
]);
|
||||
useMail.setState({
|
||||
mailboxes: {
|
||||
[ARCHIVE]: { id: ARCHIVE, role: "archive", name: "Archive", parentId: null },
|
||||
mb2026: { id: "mb2026", name: "2026", parentId: ARCHIVE },
|
||||
mb09: { id: "mb09", name: "09", parentId: "mb2026" },
|
||||
} as never,
|
||||
});
|
||||
await useMail.getState().archiveByDate(["e1"], "month");
|
||||
expect(s.created).toEqual([]);
|
||||
expect(s.moves).toEqual([{ id: "e1", to: "mb09" }]);
|
||||
});
|
||||
|
||||
it("splits a selection spanning two months into two destinations", async () => {
|
||||
const s = server();
|
||||
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "month");
|
||||
expect(s.created).toEqual([
|
||||
{ name: "2026", parentId: ARCHIVE },
|
||||
{ name: "09", parentId: "mb-new-1" },
|
||||
// August reuses the 2026 folder made a moment ago, and adds 08 beside 09.
|
||||
{ name: "08", parentId: "mb-new-1" },
|
||||
]);
|
||||
expect(s.moves).toEqual([
|
||||
{ id: "e1", to: "mb-new-2" },
|
||||
{ id: "e2", to: "mb-new-2" },
|
||||
{ id: "e3", to: "mb-new-3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the same selection to one folder at year granularity", async () => {
|
||||
const s = server();
|
||||
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "year");
|
||||
expect(s.created).toEqual([{ name: "2026", parentId: ARCHIVE }]);
|
||||
expect(new Set(s.moves.map((m) => m.to))).toEqual(new Set(["mb-new-1"]));
|
||||
});
|
||||
|
||||
it("files a message with no readable date into Archive itself", async () => {
|
||||
const s = server();
|
||||
useMail.setState({ emails: { e9: { id: "e9", receivedAt: null, mailboxIds: {} } } as never });
|
||||
await useMail.getState().archiveByDate(["e9"], "month");
|
||||
expect(s.created).toEqual([]);
|
||||
expect(s.moves).toEqual([{ id: "e9", to: ARCHIVE }]);
|
||||
});
|
||||
|
||||
it("raises one toast naming the folder, not one per group", async () => {
|
||||
server();
|
||||
await useMail.getState().archiveByDate(["e1"], "month");
|
||||
expect(messages()).toEqual(["Conversation moved to Archive/2026/09"]);
|
||||
});
|
||||
|
||||
it("says how many folders when the selection split, rather than naming one", async () => {
|
||||
server();
|
||||
await useMail.getState().archiveByDate(["e1", "e2", "e3"], "month");
|
||||
expect(messages()).toHaveLength(1);
|
||||
expect(messages()[0]).toContain("2 folders");
|
||||
});
|
||||
|
||||
it("does nothing at all without an Archive folder", async () => {
|
||||
const s = server();
|
||||
useMail.setState({ mailboxes: {} as never });
|
||||
await useMail.getState().archiveByDate(["e1"], "month");
|
||||
expect(s.created).toEqual([]);
|
||||
expect(s.moves).toEqual([]);
|
||||
expect(messages()[0]).toContain("No Archive folder");
|
||||
});
|
||||
|
||||
it("has nothing to do with an empty selection", async () => {
|
||||
const s = server();
|
||||
await useMail.getState().archiveByDate([], "month");
|
||||
expect(s.created).toEqual([]);
|
||||
expect(s.moves).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { settings } from "./settings";
|
||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
|
||||
export interface ComposeAttachment {
|
||||
id: string;
|
||||
@@ -575,8 +576,17 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
insertTemplate(key, html, subject) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
|
||||
if (subject && !d.subject) patch.subject = subject;
|
||||
// Placeholders are filled against the draft as it stands right now, which
|
||||
// is why this happens on insert rather than on send: what the template is
|
||||
// filled with is visible and editable afterwards, instead of changing
|
||||
// under the message between writing it and sending it.
|
||||
const ident = d.identityId ? useMail.getState().identities.find((i) => i.id === d.identityId) : undefined;
|
||||
const ctx: PlaceholderContext = { to: d.to, from: ident ? { name: ident.name, email: ident.email } : null, subject: d.subject };
|
||||
// The body is filled once as HTML and the plain-text side derived from the
|
||||
// result, so the two cannot disagree about what a placeholder came to.
|
||||
const filled = fillPlaceholders(html, ctx, { html: true });
|
||||
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(filled)}</div>${d.html}`, text: `${htmlToText(filled)}\n${d.text}` };
|
||||
if (subject && !d.subject) patch.subject = fillPlaceholders(subject, ctx, { html: false });
|
||||
get().update(key, patch);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { FolderRef } from "@/lib/sieveFolders";
|
||||
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||
import type {
|
||||
Comparator,
|
||||
@@ -157,6 +158,8 @@ export interface MailState {
|
||||
trash(ids: Id[]): Promise<void>;
|
||||
destroy(ids: Id[]): Promise<void>;
|
||||
archive(ids: Id[]): Promise<void>;
|
||||
/** Archive into a dated subfolder of Archive, creating the folders as needed. */
|
||||
archiveByDate(ids: Id[], granularity: ArchiveGranularity): Promise<void>;
|
||||
spam(ids: Id[], isSpam: boolean): Promise<void>;
|
||||
emptyMailbox(mailboxId: Id): Promise<void>;
|
||||
/** Mark every unread message in a mailbox read; optionally its subfolders too. */
|
||||
@@ -577,6 +580,66 @@ export const useMail = create<MailState>((set, get) => ({
|
||||
await get().move(ids, archiveId, { label: "Archive" });
|
||||
},
|
||||
|
||||
async archiveByDate(ids, granularity) {
|
||||
const accountId = get().accountId;
|
||||
const archiveId = get().roleId("archive") ?? get().roleId("all");
|
||||
if (!accountId || !ids.length) return;
|
||||
if (!archiveId) {
|
||||
toast.error(t("No Archive folder found. Create one named “Archive” first."));
|
||||
return;
|
||||
}
|
||||
const { emails } = get();
|
||||
const groups = groupByArchivePath(ids.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
|
||||
|
||||
// Where everything came from, captured before anything moves, so one Undo
|
||||
// can put back a selection that went to several folders.
|
||||
const prev: Record<Id, Record<Id, boolean>> = {};
|
||||
for (const id of ids) prev[id] = emails[id]?.mailboxIds ?? {};
|
||||
|
||||
const moved: string[] = [];
|
||||
try {
|
||||
for (const group of groups) {
|
||||
const target = await ensureFolderPath(get, archiveId, group.segments);
|
||||
// Silent: each group would otherwise raise its own toast with its own
|
||||
// Undo, and undoing one third of a move is not what anybody meant.
|
||||
await get().move(group.ids, target, { silent: true });
|
||||
moved.push(group.segments.length ? `Archive/${archivePath(group.segments)}` : "Archive");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("Archive failed: {error}", { error: (err as Error).message }));
|
||||
void get().getEmails(ids);
|
||||
void get().refreshList();
|
||||
return;
|
||||
}
|
||||
|
||||
// One message naming every destination, because a selection that split
|
||||
// across months should say so rather than claiming a single folder.
|
||||
const where = moved.length === 1 ? moved[0]! : t("{count} folders", { count: String(moved.length) });
|
||||
toast.show(
|
||||
ids.length === 1
|
||||
? t("Conversation moved to {folder}", { folder: where })
|
||||
: t("{count} conversations moved to {folder}", { count: String(ids.length), folder: where }),
|
||||
{
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: async () => {
|
||||
const undo: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) undo[id] = { mailboxIds: prev[id] };
|
||||
await setEmails(accountId, undo);
|
||||
set((st) => {
|
||||
const next = { ...st.emails };
|
||||
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: prev[id]! };
|
||||
return { emails: next };
|
||||
});
|
||||
void get().refreshList();
|
||||
void get().loadMailboxes();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
void get().loadMailboxes();
|
||||
},
|
||||
|
||||
async spam(ids, isSpam) {
|
||||
const { roleId } = get();
|
||||
const target = isSpam ? roleId("junk") : roleId("inbox");
|
||||
@@ -1083,6 +1146,27 @@ export function mailboxIcon(role: MailboxRole): string {
|
||||
|
||||
export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
|
||||
|
||||
/**
|
||||
* Resolve `parentId/segments...` to a mailbox id, creating what is missing.
|
||||
*
|
||||
* Reuses a folder that is already there rather than making a second one beside
|
||||
* it, so archiving by month twice in the same month files into the same place
|
||||
* -- including a folder somebody made by hand, or one another client made
|
||||
* first, which is the usual way `Archive/2026` already exists.
|
||||
*
|
||||
* Sequential on purpose: each level is the next level's parent, and
|
||||
* `createMailbox` reloads the tree, so the lookup for `09` can see the `2026`
|
||||
* that was just created.
|
||||
*/
|
||||
async function ensureFolderPath(state: () => MailState, parentId: Id, segments: string[]): Promise<Id> {
|
||||
let current = parentId;
|
||||
for (const name of segments) {
|
||||
const existing = Object.values(state().mailboxes).find((m) => m.parentId === current && m.name === name);
|
||||
current = existing ? existing.id : await state().createMailbox(name, current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder and everything under it, with the paths they have right now.
|
||||
*
|
||||
|
||||
@@ -1386,3 +1386,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.spam-weight { font-family: var(--font-mono); font-size: 12px; text-align: right; color: var(--fg-muted); }
|
||||
.spam-weight.bad { color: var(--danger); }
|
||||
.spam-weight.good { color: var(--success); }
|
||||
|
||||
/* The placeholder reference under a template's body. */
|
||||
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
||||
.placeholder-row { display: contents; }
|
||||
.placeholder-list code { font-family: var(--font-mono); font-size: 12.5px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 4px; padding: .1em .4em; white-space: nowrap; }
|
||||
|
||||
@@ -4,9 +4,10 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea
|
||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { useSwipeNav } from "@/lib/touch";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||
import { Empty, useIsMobile } from "@/ui/misc";
|
||||
import { Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||
@@ -90,6 +91,25 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
else go(view, addDays(anchor, 30 * n));
|
||||
};
|
||||
|
||||
/*
|
||||
* Swipe sideways to step the calendar, on a touchscreen only and only in the
|
||||
* two views where a period is a page: day and month. Week and agenda scroll
|
||||
* through a range rather than turning to the next one, so there is nothing a
|
||||
* sideways flick would obviously mean.
|
||||
*
|
||||
* The buttons in the toolbar stay, and so does n/p. A gesture with no
|
||||
* visible control is one only the people who already know about it can use.
|
||||
*/
|
||||
const [mainEl, setMainEl] = useState<HTMLDivElement | null>(null);
|
||||
const isTouch = useIsTouch();
|
||||
useSwipeNav(mainEl, {
|
||||
enabled: isTouch && (effectiveView === "day" || effectiveView === "month"),
|
||||
onStep: (n) => step(n),
|
||||
// Buttons live in the toolbar; an event is where a future drag-to-move
|
||||
// gesture has to start, so this one keeps out of both.
|
||||
ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev",
|
||||
});
|
||||
|
||||
const openNew = useCallback(
|
||||
(start?: Date, end?: Date, allDay = false) => {
|
||||
const s = start ?? roundToNext(new Date(), 30);
|
||||
@@ -147,7 +167,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cal-main">
|
||||
<div className="cal-main" ref={setMainEl}>
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
|
||||
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMail, type ListState } from "@/store/mail";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
@@ -224,6 +225,22 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
);
|
||||
|
||||
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
|
||||
|
||||
/*
|
||||
* Name the destination where there is only one, so the menu says where the
|
||||
* mail is actually going rather than describing the rule. A selection that
|
||||
* spans months has no single answer, and claiming one would be worse than
|
||||
* naming the rule -- so that case falls back to it.
|
||||
*/
|
||||
const archiveDateLabel = useCallback(
|
||||
(granularity: ArchiveGranularity) => {
|
||||
const groups = groupByArchivePath(ctxTargets.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
|
||||
const only = groups.length === 1 ? groups[0]! : null;
|
||||
if (only?.segments.length) return t("Archive to {folder}", { folder: archivePath(only.segments) });
|
||||
return granularity === "year" ? t("Archive by year") : t("Archive by month");
|
||||
},
|
||||
[ctxTargets, emails],
|
||||
);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||
@@ -478,6 +495,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
|
||||
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||
<MenuSep />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
|
||||
import { RichEditor } from "../compose/RichEditor";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
|
||||
|
||||
export function TemplatesSettings() {
|
||||
const templates = useSettings((s) => s.settings.templates);
|
||||
@@ -37,8 +38,48 @@ export function TemplatesSettings() {
|
||||
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
|
||||
</div>
|
||||
</div>
|
||||
<PlaceholderReference />
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What each placeholder answers, shown under the body so the names are
|
||||
* discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather
|
||||
* than from this map, so a name added to the lib and forgotten here appears
|
||||
* with no description instead of quietly not appearing at all.
|
||||
*/
|
||||
function placeholderHint(name: string): string {
|
||||
switch (name) {
|
||||
case "recipientName": return translate("Who the message is addressed to");
|
||||
case "recipientFirstName": return translate("Their first name alone");
|
||||
case "recipientEmail": return translate("Their address");
|
||||
case "myName": return translate("The name on the identity you are sending as");
|
||||
case "myEmail": return translate("That identity's address");
|
||||
case "subject": return translate("The subject already on the message");
|
||||
case "date": return translate("Today, in your date format");
|
||||
case "time": return translate("Now, on your clock");
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function PlaceholderReference() {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{translate("Placeholders")}</label>
|
||||
<div className="placeholder-list">
|
||||
{PLACEHOLDER_NAMES.map((name) => (
|
||||
<div key={name} className="placeholder-row">
|
||||
<code>{`{{${name}}}`}</code>
|
||||
<span className="hint">{placeholderHint(name)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint">
|
||||
{translate("Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user