Compare commits

...
Author SHA1 Message Date
jcoffey 2740129c6a Keep only the app page as the app page (#396)
The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.

The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
2026-09-16 15:07:45 -07:00
jcoffey 82dc877fe1 Start at once on a device marked as your own (#395)
* Start at once on a device marked as your own

On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.

A trusted device now starts from what it kept:

- the service worker answers an app route from its kept page and fetches a
  fresh one behind it; the app checks the server's version at start, and a
  reload for a new build puts the new page in place first, so it is not
  answered with the old one. Assets of the page just replaced are kept one
  build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
  server has confirmed the session; the answer replaces it, and a session
  that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
  list properties only, so the folders and the inbox paint before any reply
  and the folder query does not wait on the folder list. The "folder no
  longer exists" check still waits for the server's list.

All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.

* Show nothing kept before the session is confirmed

Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.

The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
2026-09-16 13:47:59 -07:00
jcoffey 4c67460450 Fetch the rest of a new build in the background (#394)
The app page names only what it loads at start. The composer, settings,
viewers and the rest were fetched when first used, and after every deploy
that first use waited on the server -- and the worker's tidy-up dropped
them again at the next deploy anyway.

The build now writes the list of all its files into the page as an inert
JSON block. The service worker keeps everything listed and, once a page
names files it does not hold, fetches them three at a time; a load cut
short is resumed at the next navigation. Language catalogs are listed
apart and left to be cached when used, and nothing is fetched ahead when
the browser is set to save data.
2026-09-16 13:29:57 -07:00
jcoffey e158ebac5a Fold a push's follow-up requests together (#393)
A pushed mail change took three round trips: Email/changes beside a
Mailbox/get, then Email/get for what changed, then the list, the open
thread and a second Mailbox/get. Each page of changes now carries its own
Email/get calls by back-reference, and the one Mailbox/get goes out with
it, so a push settles in two. New mail fetched this way is not asked for
again by the notice.

A reply's sessionState that differs from the session is announced once
rather than on every reply, and session refreshes in flight are shared.
The mock's session state now matches the sessionState on its replies, as
Stalwart's does; tying it to the data counter made every reply trigger a
session refresh in development.
2026-09-16 13:22:26 -07:00
jcoffey 786976312f Open conversations in one request, and start them early (#392)
On a 250 ms link, opening a conversation took two round trips: Thread/get,
then the bodies. It now takes one. A known thread sends Thread/get and the
missing bodies in the same tick; an unknown one chains Email/get off
Thread/get with a back-reference, and falls back to fetching in parts when
the thread is longer than one Email/get may carry.

Conversations also start loading before the click: when the pointer rests
on a row, as soon as a press begins, and for the row below the open one.
The open waits for that load and does not repeat it.

Going back to one of the last twelve folders shows its previous list at
once, less messages that have left it, while the query runs.
2026-09-16 13:15:21 -07:00
jcoffey 5fe89d6e15 Merge pull request #391 from Coffey-Labs/feat/share-confirm
Ask before opening a shared item in a message
2026-09-16 12:27:33 -07:00
jcoffey-dev f79915aa89 Drop a wrong issue reference from a comment 2026-09-16 12:23:47 -07:00
jcoffey-dev 191c4e7e68 Ask before opening a shared item in a message
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
2026-09-16 12:23:27 -07:00
jcoffey 4c1ceca8e9 Merge pull request #390 from Coffey-Labs/fix/accept-ranges
Advertise byte ranges on downloads, and record the live checks
2026-09-16 12:13:20 -07:00
jcoffey-dev 8a08c3d6db Advertise byte ranges on downloads, and record the live checks
Stalwart honors a single byte range on its download endpoint but sends
no Accept-Ranges, and Chrome's PDF viewer only reads a file in pieces
when the first response says it can. The proxy now says so itself.

Checked live on 0.16.22: ContactCard/changes reports creates, updates
and destroys exactly, which the contacts store's sync relies on, and a
range the server cannot serve gets the whole file with 200, never 416.
The mock now answers ranges the same way and sends no Accept-Ranges.
2026-09-16 12:10:20 -07:00
34 changed files with 1125 additions and 71 deletions
+18 -5
View File
@@ -1414,8 +1414,13 @@ server settings is deliberately out of scope.
# Platform # Platform
- **Installable PWA** with a service worker: the app shell is cached for - **Installable PWA** with a service worker: the app shell is cached for
installability and fast loads, API requests never are, and navigations are installability and fast loads, API requests never are. An app route is
network-first with the shell as fallback. answered from the kept shell at once while a fresh copy is fetched behind it;
a shell a build behind is caught by the version check at start and reloaded.
After a new version is seen, the rest of its code (composer, settings,
viewers) is fetched in the background, so opening them later does not wait on
the server; language catalogs are cached when first used, and nothing is
fetched ahead when the browser is set to save data.
- **Manifest shortcuts** for Compose, Calendar and Contacts. - **Manifest shortcuts** for Compose, Calendar and Contacts.
- **One window, not one per launch.** A `mailto:` link, a shortcut or a - **One window, not one per launch.** A `mailto:` link, a shortcut or a
notification opened while ihasmail is already running arrives in the copy notification opened while ihasmail is already running arrives in the copy
@@ -1545,15 +1550,23 @@ costs something to get wrong is the one that assumes the machine is yours.
| --- | --- | --- | | --- | --- | --- |
| Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) | | Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) |
| Idle sign-out | after 5 minutes | none | | Idle sign-out | after 5 minutes | none |
| Kept on the computer | nothing | settings cache, recent addresses, username | | Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) |
| Background notifications | refused | available | | Background notifications | refused | available |
| Administration | unavailable | available, if the role allows it | | Administration | unavailable | available, if the role allows it |
Local storage is gated on that answer for **reads** as well as writes — a Local storage is gated on that answer for **reads** as well as writes — a
machine trusted once still has residue, and honoring it would let a previous machine trusted once still has residue, and honoring it would let a previous
session's data surface in a later untrusted one. Signing out clears the settings session's data surface in a later untrusted one. Signing out clears the settings
cache and recent addresses and tears down the push subscription, whichever cache, recent addresses and kept folder list, and tears down the push
answer was given. subscription, whichever answer was given.
What a ticked device keeps is what makes it **start quickly on a distant
link**: once the server has confirmed the session, the folders and the inbox
paint from the kept copy straight away, and the request for the open folder
goes out without waiting on the folder list first. The server's answers
replace the copy a round trip later. **Nothing kept is shown before the session
is confirmed** — until then the app shows a spinner, so a session that has
ended goes from the spinner to the sign-in form and never past a mailbox.
The idle timer exists because the alternative does not work: `beforeunload` text The idle timer exists because the alternative does not work: `beforeunload` text
was removed from browsers years ago, and **no event fires at all** for walking was removed from browsers years ago, and **no event fires at all** for walking
+2
View File
@@ -48,6 +48,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged 0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support). [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)). - **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://github.com/Coffey-Labs/ihasmail/issues/375)).
- **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)). - **A contact photo has to be a `data:` URI; Stalwart refuses one given as a `blobId`.** RFC 9610 lets JMAP put a `blobId` in a JSContact `Media` object, and ihasmail uploaded the photo and saved it that way, which the mock accepted. Stalwart does not: **confirmed live (0.16.22, 2026-09-16)**, a `ContactCard/set` create with `media.*.blobId` fails with `invalidProperties` on `media`, "blobIds in media is not supported." The RFC 9553 `uri` form with a `data:image/jpeg;base64,…` value is accepted on create and on update, and `ContactCard/get` returns it unchanged; a 134 KB one was accepted. Photos are now saved inline, and the mock refuses a `blobId` the same way ([#376](https://github.com/Coffey-Labs/ihasmail/issues/376)).
+4 -1
View File
@@ -127,9 +127,12 @@ test("a download passes a byte range through, for viewers that read in pieces",
assert.equal(await part.text(), "hello"); assert.equal(await part.text(), "hello");
const whole = await app.request(url, { headers: { cookie } }); const whole = await app.request(url, { headers: { cookie } });
assert.equal(whole.status, 200); assert.equal(whole.status, 200);
assert.equal(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
assert.equal(await whole.text(), "hello world"); assert.equal(await whole.text(), "hello world");
// Past the end, Stalwart sends the whole file rather than a 416.
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } }); const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 416); assert.equal(beyond.status, 200);
assert.equal(await beyond.text(), "hello world");
// Anything that is not a plain byte range is not passed on. // Anything that is not a plain byte range is not passed on.
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } }); const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
assert.equal(odd.status, 200); assert.equal(odd.status, 200);
+8 -1
View File
@@ -822,7 +822,14 @@ export function createApp(basePath = config.basePath): Hono<Env> {
if (cl) headers.set("Content-Length", cl); if (cl) headers.set("Content-Length", cl);
const partial = res.status === 206 && res.headers.get("content-range"); const partial = res.status === 206 && res.headers.get("content-range");
if (partial) headers.set("Content-Range", partial); if (partial) headers.set("Content-Range", partial);
if (res.headers.get("accept-ranges") === "bytes") headers.set("Accept-Ranges", "bytes"); /*
* Said here because Stalwart does not say it. It honors a single byte
* range but sends no `Accept-Ranges` (0.16.22, checked live on
* 2026-09-16), and Chrome's PDF viewer only reads a file in pieces when
* the first response advertises it. A server that ignores a range sends
* the whole file, which the browser takes just as well.
*/
headers.set("Accept-Ranges", "bytes");
const safeInline = inline && isInlineSafe(type); const safeInline = inline && isInlineSafe(type);
headers.set( headers.set(
"Content-Disposition", "Content-Disposition",
+19 -9
View File
@@ -7,6 +7,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import { ACCOUNT, MAX_DELAYED_SEND, MOCK_EDITION, MOCK_LOCALE, NO_REGISTRY, Obj, PASS, PERMISSION_SNAPSHOT, PORT, SHARED_ACCOUNT, SHARED_CAPS, USER, account, nextState, state } from "./config.js"; import { ACCOUNT, MAX_DELAYED_SEND, MOCK_EDITION, MOCK_LOCALE, NO_REGISTRY, Obj, PASS, PERMISSION_SNAPSHOT, PORT, SHARED_ACCOUNT, SHARED_CAPS, USER, account, nextState, state } from "./config.js";
const SESSION_STATE = "1";
import { PING_FLOOR_SECONDS, addEmail, blobs, calendars, people, principals, putBlob, recount } from "./data.js"; import { PING_FLOOR_SECONDS, addEmail, blobs, calendars, people, principals, putBlob, recount } from "./data.js";
import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from "./engine.js"; import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from "./engine.js";
import { handlers } from "./handlers.js"; import { handlers } from "./handlers.js";
@@ -63,7 +65,12 @@ const session = () => ({
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`, uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`,
eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`, eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`,
state: String(state.n), /*
* The session's own state, which the account's data changes do not move.
* It matches the sessionState on every JMAP reply below, as Stalwart's does;
* tying it to the data counter made every reply look like a session change.
*/
state: SESSION_STATE,
}); });
@@ -125,7 +132,7 @@ export const server = createServer(async (req, res) => {
} }
if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); } if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); }
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" })); return res.end(JSON.stringify({ methodResponses: responses, sessionState: SESSION_STATE }));
} }
if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") { if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") {
const data = await readBody(req); const data = await readBody(req);
@@ -139,20 +146,23 @@ export const server = createServer(async (req, res) => {
const b = blobs.get(blobId ?? ""); const b = blobs.get(blobId ?? "");
if (!b) { res.writeHead(404); return res.end(); } if (!b) { res.writeHead(404); return res.end(); }
const type = url.searchParams.get("accept") ?? b.type; const type = url.searchParams.get("accept") ?? b.type;
// One byte range, the way a PDF viewer or a video element asks for one. /*
* One byte range, answered as Stalwart answers it (0.16.22, checked live
* on 2026-09-16): a 206 for a single range it can serve, and the whole
* file with a 200 for anything else -- several ranges, or one past the
* end. It never sends Accept-Ranges.
*/
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? "")); const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
if (m && (m[1] || m[2])) { if (m && (m[1] || m[2])) {
const size = b.data.length; const size = b.data.length;
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2])); const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1; const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start >= size || start > end) { if (start < size && start <= end) {
res.writeHead(416, { "content-range": `bytes */${size}` }); res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}` });
return res.end();
}
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" });
return res.end(b.data.subarray(start, end + 1)); return res.end(b.data.subarray(start, end + 1));
} }
res.writeHead(200, { "content-type": type, "content-length": b.data.length, "accept-ranges": "bytes" }); }
res.writeHead(200, { "content-type": type, "content-length": b.data.length });
return res.end(b.data); return res.end(b.data);
} }
/* /*
+118 -10
View File
@@ -18,16 +18,42 @@ const VERSION = "ihasmail-v2";
* eventually would. * eventually would.
*/ */
const BASE = new URL("./", self.location).pathname.replace(/\/$/, ""); const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`]; const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
/*
* Only the app page may be kept as the app page.
*
* The mount's root is not always the app: demo.ihasmail.com puts its landing
* page there, and a front door of any kind can. The worker used to cache
* whatever `/` returned at install and whatever HTML a navigation returned,
* and since app routes are answered from that copy first, a demo visitor who
* came back got the landing page on every route, for good. The app page is
* recognised by the asset list the build writes into it.
*/
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
/*
* The routes the app itself owns (App.tsx). Only these are answered from the
* kept page; anything else under the mount -- the root, a landing or farewell
* page in front of the app, a file -- goes to the network as it always did.
*/
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); event.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
.then(() => self.skipWaiting())
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => dropForeignShell())
.then(() => tidy()) .then(() => tidy())
.catch(() => {}) .catch(() => {})
.then(() => self.clients.claim()) .then(() => self.clients.claim())
@@ -67,12 +93,17 @@ function assetsNamedIn(html) {
return out; return out;
} }
/** Drop failed responses, and assets the cached app page does not name. */ /**
async function tidy() { * Drop failed responses, and assets the cached app page does not name. `also`
* is a page whose assets are kept as well: the one just replaced, which a tab
* opened from the kept copy may still be running.
*/
async function tidy(also = "") {
const cache = await caches.open(VERSION); const cache = await caches.open(VERSION);
const shell = await cache.match(SHELL_KEY); const shell = await cache.match(SHELL_KEY);
// Without a page to go by, which assets are current is unknown; keep them. // Without a page to go by, which assets are current is unknown; keep them.
const keep = shell ? assetsNamedIn(await shell.text()) : null; const keep = shell ? assetsNamedIn(await shell.text()) : null;
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
for (const req of await cache.keys()) { for (const req of await cache.keys()) {
const path = new URL(req.url).pathname; const path = new URL(req.url).pathname;
if (path.startsWith(ASSETS)) { if (path.startsWith(ASSETS)) {
@@ -86,14 +117,68 @@ async function tidy() {
} }
} }
/** Keep the offline copy of the app page current, and tidy when it changes. */ /** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
async function dropForeignShell() {
const cache = await caches.open(VERSION);
const kept = await cache.match(SHELL_KEY);
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
}
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) { async function refreshShell(res) {
const html = await res.text(); const html = await res.text();
if (!isAppPage(html)) return;
const cache = await caches.open(VERSION); const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY); const prev = await cache.match(SHELL_KEY);
if (prev && (await prev.text()) === html) return; const prevHtml = prev ? await prev.text() : "";
if (prevHtml !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy(); await tidy(prevHtml);
}
await precache(html);
}
/*
* Fetching the rest of the build before it is asked for.
*
* The app page lists every file of its build (see the asset-list plugin in
* vite.config.ts). Without this, the first time after a deploy that a reader
* opened the composer, settings or a viewer, it waited on the server for the
* code -- on a distant link, a visible pause. Now those files are fetched
* quietly once a page names them, a few at a time, and only those not held
* already; a load cut short is carried on at the next navigation, which calls
* this again. Language catalogs are left to be cached when used, and nothing
* is fetched ahead when the reader has asked the browser to save data.
*/
const PRECACHE_PARALLEL = 3;
function precacheList(html) {
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
if (!m) return [];
try {
const list = JSON.parse(m[1]).precache;
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
} catch {
return [];
}
}
async function precache(html) {
if (self.navigator.connection && self.navigator.connection.saveData) return;
const cache = await caches.open(VERSION);
const wanted = [];
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
const next = async () => {
for (let path = wanted.shift(); path; path = wanted.shift()) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (res.ok) await cache.put(path, res);
} catch {
/* offline, or a deploy changing over; the next navigation tries again */
}
}
};
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
} }
/* /*
@@ -183,15 +268,38 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Navigations & everything else: network-first, fall back to cached shell. /*
* Navigations: the kept app page at once, and the network's behind it.
*
* Every route in the app is the same page, and waiting on the server for it
* cost a full round trip before anything could start -- the longest single
* wait on a distant link. So a route is answered from the kept copy when
* there is one, and the fresh page is fetched alongside to replace it for
* next time. A page that is a build behind is caught the way it always was:
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then.
*
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
* app, and a file opened in a tab of its own go to the network as before. So
* does the first visit, which has no copy yet.
*/
if (req.mode === "navigate") { if (req.mode === "navigate") {
event.respondWith(fetch(req).then((res) => { const network = fetch(req).then((res) => {
// Every route is the same app page; a fresh one replaces the offline copy. // Every route is the same app page; a fresh one replaces the offline copy.
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) { if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
event.waitUntil(refreshShell(res.clone()).catch(() => {})); event.waitUntil(refreshShell(res.clone()).catch(() => {}));
} }
return res; return res;
}).catch(() => caches.match(SHELL_KEY))); });
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) {
event.waitUntil(network.catch(() => {}));
return kept;
}
return network.catch(() => caches.match(SHELL_KEY));
})());
return; return;
} }
event.respondWith(fetch(req).catch(() => caches.match(req))); event.respondWith(fetch(req).catch(() => caches.match(req)));
+4 -1
View File
@@ -104,6 +104,8 @@ export class JmapClient {
private callCounter = 0; private callCounter = 0;
private unauthHandlers = new Set<() => void>(); private unauthHandlers = new Set<() => void>();
private stateHandlers = new Set<(sessionState: string) => void>(); private stateHandlers = new Set<(sessionState: string) => void>();
/** The last session state announced, so a burst of replies announces it once. */
private announcedState: string | null = null;
get maxCallsInRequest(): number { get maxCallsInRequest(): number {
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined; const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
@@ -255,7 +257,8 @@ export class JmapClient {
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls }; const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds; if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) }); const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
if (res.sessionState && this.session && res.sessionState !== this.session.state) { if (res.sessionState && this.session && res.sessionState !== this.session.state && res.sessionState !== this.announcedState) {
this.announcedState = res.sessionState;
for (const fn of this.stateHandlers) fn(res.sessionState); for (const fn of this.stateHandlers) fn(res.sessionState);
} }
return res; return res;
+17 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget"; import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/sw/swCache"; import { SW_CACHE_NAME } from "@/lib/sw/swCache";
/** /**
@@ -112,3 +112,19 @@ describe("the body a share turns into", () => {
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought"); expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
}); });
}); });
describe("shareSummary", () => {
const file = (name: string) => new File(["x"], name);
it("gives the title, the text and link together, and the file names", () => {
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
title: "Trip",
preview: "See this https://example.com",
files: ["a.jpg"],
});
});
it("shortens a long text rather than showing all of it", () => {
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
expect(preview.length).toBeLessThanOrEqual(160);
expect(preview.endsWith("…")).toBe(true);
});
});
+14
View File
@@ -117,3 +117,17 @@ export function shareBody(share: Pick<SharedContent, "text" | "url">): string {
if (!url || text.includes(url)) return text; if (!url || text.includes(url)) return text;
return text ? `${text}\n\n${url}` : url; return text ? `${text}\n\n${url}` : url;
} }
/**
* What a share holds, in the few words the confirmation shows.
*
* Only what the reader needs to recognize it as theirs: the title, the start
* of the text or link, and the names of the files. It is shown before any of
* it goes near a message, because the page cannot tell a share the reader
* made from one a website posted at the same address.
*/
export function shareSummary(share: SharedContent): { title: string; preview: string; files: string[] } {
const body = [share.text, share.url].map((s) => s.trim()).filter(Boolean).join(" ");
const preview = body.length > 160 ? `${body.slice(0, 157).trimEnd()}` : body;
return { title: share.title.trim(), preview, files: share.files.map((f) => f.name) };
}
+28
View File
@@ -1,5 +1,6 @@
import { APP_VERSION } from "../version"; import { APP_VERSION } from "../version";
import { withBase } from "../basePath"; import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "./swCache";
import { push, type PushState } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
/** /**
@@ -93,10 +94,33 @@ async function check(): Promise<boolean> {
// deploy -- this stops the two of them reloading each other in a loop. // deploy -- this stops the two of them reloading each other in a loop.
if (tried() === serverVersion) return false; if (tried() === serverVersion) return false;
remember(serverVersion); remember(serverVersion);
await primeShell();
window.location.reload(); window.location.reload();
return true; return true;
} }
/*
* The service worker answers a navigation from its kept copy of the app page
* and refreshes that copy behind it. A reload for a new build must not get the
* old copy back, so the new page is put in place first. Best effort: if this
* fails, the loop guard above still stops a second reload.
*/
async function primeShell(): Promise<void> {
if (!("caches" in window) || !navigator.serviceWorker?.controller) return;
try {
// An app route rather than the root: the root can be a page in front of
// the app (the demo's landing page is), and only the app page may be kept.
const res = await fetch(withBase("/mail"), { credentials: "same-origin", cache: "no-store" });
if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return;
const html = await res.text();
if (!html.includes('id="ihasmail-assets"')) return;
const cache = await caches.open(SW_CACHE_NAME);
await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
} catch {
/* reload anyway */
}
}
/** /**
* Watch for a deploy without waiting to be asked. * Watch for a deploy without waiting to be asked.
* *
@@ -139,6 +163,10 @@ export function makeConnectionWatcher(): (state: PushState) => void {
export function startBuildWatch(): void { export function startBuildWatch(): void {
push.onConnection(makeConnectionWatcher()); push.onConnection(makeConnectionWatcher());
// A page the service worker answered from its kept copy may be a build
// behind; ask now rather than a minute from now.
if (navigator.serviceWorker?.controller) void reloadIfServerRebuilt();
window.setInterval(() => { window.setInterval(() => {
// A hidden tab is not being read, and will be checked when it surfaces. // A hidden tab is not being read, and will be checked when it surfaces.
if (document.visibilityState === "visible") void reloadIfServerRebuilt(); if (document.visibilityState === "visible") void reloadIfServerRebuilt();
+3
View File
@@ -1190,6 +1190,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Neue Nachricht", "New message": "Neue Nachricht",
"Start a new message with what was shared?": "Neue Nachricht mit dem geteilten Inhalt beginnen?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Es wurde etwas mit ihasmail geteilt. Gesendet wird erst, wenn Sie „Senden“ wählen. Wenn Sie dies nicht gerade selbst geteilt haben, verwerfen Sie es.",
"Start a message": "Nachricht beginnen",
"New mail": "Neue E-Mail", "New mail": "Neue E-Mail",
"Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut", "Could not do that — open ihasmail and try again": "Nicht möglich öffnen Sie ihasmail und versuchen Sie es erneut",
"Sending…": "Wird gesendet…", "Sending…": "Wird gesendet…",
+3
View File
@@ -1163,6 +1163,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Mensaje nuevo", "New message": "Mensaje nuevo",
"Start a new message with what was shared?": "¿Empezar un mensaje nuevo con lo que se ha compartido?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Se ha compartido algo con ihasmail. No se envía nada hasta que elija Enviar. Si no acaba de compartirlo usted, descártelo.",
"Start a message": "Empezar mensaje",
"New mail": "Correo nuevo", "New mail": "Correo nuevo",
"Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo", "Could not do that — open ihasmail and try again": "No se pudo hacer eso: abra ihasmail e inténtelo de nuevo",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1168,6 +1168,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nouveau message", "New message": "Nouveau message",
"Start a new message with what was shared?": "Commencer un nouveau message avec le contenu partagé ?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Un contenu a été partagé avec ihasmail. Rien n'est envoyé tant que vous n'avez pas choisi Envoyer. Si vous ne venez pas de le partager, abandonnez-le.",
"Start a message": "Commencer un message",
"New mail": "Nouveau courrier", "New mail": "Nouveau courrier",
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez", "Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
"Sending…": "Envoi…", "Sending…": "Envoi…",
+3
View File
@@ -1171,6 +1171,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新規メール", "New message": "新規メール",
"Start a new message with what was shared?": "共有された内容で新規メールを作成しますか?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "ihasmail に何かが共有されました。「送信」を選ぶまで何も送信されません。共有した覚えがない場合は破棄してください。",
"Start a message": "メールを作成",
"New mail": "新着メール", "New mail": "新着メール",
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください", "Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
"Sending…": "送信中…", "Sending…": "送信中…",
+3
View File
@@ -1161,6 +1161,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nieuw bericht", "New message": "Nieuw bericht",
"Start a new message with what was shared?": "Een nieuw bericht beginnen met wat is gedeeld?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Er is iets met ihasmail gedeeld. Er wordt niets verzonden totdat u Verzenden kiest. Hebt u dit niet zelf zojuist gedeeld, gooi het dan weg.",
"Start a message": "Bericht beginnen",
"New mail": "Nieuwe e-mail", "New mail": "Nieuwe e-mail",
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw", "Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
"Sending…": "Bezig met verzenden…", "Sending…": "Bezig met verzenden…",
+3
View File
@@ -1166,6 +1166,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Nova mensagem", "New message": "Nova mensagem",
"Start a new message with what was shared?": "Iniciar uma nova mensagem com o que foi compartilhado?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "Algo foi compartilhado com o ihasmail. Nada é enviado até você escolher Enviar. Se não foi você que acabou de compartilhar, descarte.",
"Start a message": "Iniciar mensagem",
"New mail": "Novo e-mail", "New mail": "Novo e-mail",
"Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente", "Could not do that — open ihasmail and try again": "Não foi possível fazer isso — abra o ihasmail e tente novamente",
"Sending…": "Enviando…", "Sending…": "Enviando…",
+3
View File
@@ -1165,6 +1165,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новое письмо", "New message": "Новое письмо",
"Start a new message with what was shared?": "Начать новое письмо с полученным содержимым?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "В ihasmail что-то передали через «Поделиться». Ничего не отправится, пока вы не нажмёте «Отправить». Если вы только что ничего не передавали, нажмите «Не сохранять».",
"Start a message": "Начать письмо",
"New mail": "Новое письмо", "New mail": "Новое письмо",
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку", "Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
"Sending…": "Отправка…", "Sending…": "Отправка…",
+3
View File
@@ -1159,6 +1159,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "Новий лист", "New message": "Новий лист",
"Start a new message with what was shared?": "Почати новий лист з отриманим вмістом?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "До ihasmail щось передали через «Поділитися». Нічого не буде надіслано, доки ви не натиснете «Надіслати». Якщо ви щойно нічого не передавали, натисніть «Не зберігати».",
"Start a message": "Почати лист",
"New mail": "Новий лист", "New mail": "Новий лист",
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу", "Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
"Sending…": "Надсилання…", "Sending…": "Надсилання…",
+3
View File
@@ -1170,6 +1170,9 @@ export const catalog: Catalog = {
// ── Composer status, calendar title ──────────────────────────────── // ── Composer status, calendar title ────────────────────────────────
"New message": "新邮件", "New message": "新邮件",
"Start a new message with what was shared?": "用共享的内容新建邮件吗?",
"Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.": "有内容被共享到 ihasmail。在您选择“发送”之前不会发送任何内容。如果不是您刚才共享的,请放弃。",
"Start a message": "新建邮件",
"New mail": "新邮件", "New mail": "新邮件",
"Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试", "Could not do that — open ihasmail and try again": "无法执行 — 请打开 ihasmail 后重试",
"Sending…": "正在发送…", "Sending…": "正在发送…",
+89 -3
View File
@@ -67,7 +67,7 @@ function server(count: number, threadSize = 1) {
return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "1" }) } as Response; return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "1" }) } as Response;
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
return { calls, listed }; return { calls, listed, requests: () => fetchMock.mock.calls.length };
} }
const getSizes = (calls: Call[]) => calls.filter(([n]) => n === "Email/get").map(([, a]) => (a.ids as string[]).length); const getSizes = (calls: Call[]) => calls.filter(([n]) => n === "Email/get").map(([, a]) => (a.ids as string[]).length);
@@ -79,6 +79,7 @@ beforeEach(() => {
primaryAccounts: {}, primaryAccounts: {},
state: "s1", state: "s1",
} as unknown as JmapSession; } as unknown as JmapSession;
useMail.getState().setAccount("a1");
useMail.setState({ useMail.setState({
accountId: "a1", accountId: "a1",
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never, mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
@@ -177,8 +178,11 @@ describe("merging a refresh", () => {
}); });
describe("loadThread", () => { describe("loadThread", () => {
const known = (t: string, emailIds: string[]) => useMail.setState({ threads: { [t]: { id: t, emailIds } } });
it("fetches no bodies for messages already held in full", async () => { it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3); const { calls } = server(1, 3);
known("te0", ["e0", "e0m0", "e0m1"]);
useMail.setState({ useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never, emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true, e0m1: true }, fullIds: { e0: true, e0m0: true, e0m1: true },
@@ -191,13 +195,15 @@ describe("loadThread", () => {
expect(useMail.getState().emails.e0).toBe(before); expect(useMail.getState().emails.e0).toBe(before);
}); });
it("fetches in full only the message it does not have", async () => { it("fetches in full only the message it does not have, in the same request as the thread", async () => {
const { calls } = server(1, 3); const { calls, requests } = server(1, 3);
known("te0", ["e0", "e0m0", "e0m1"]);
useMail.setState({ useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never, emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true }, fullIds: { e0: true, e0m0: true },
}); });
await useMail.getState().loadThread("te0"); await useMail.getState().loadThread("te0");
expect(requests()).toBe(1);
const gets = calls.filter(([n]) => n === "Email/get"); const gets = calls.filter(([n]) => n === "Email/get");
expect(gets).toHaveLength(1); expect(gets).toHaveLength(1);
expect(gets[0]![1].ids).toEqual(["e0m1"]); expect(gets[0]![1].ids).toEqual(["e0m1"]);
@@ -206,10 +212,90 @@ describe("loadThread", () => {
expect(useMail.getState().loadingThreads).toEqual({}); expect(useMail.getState().loadingThreads).toEqual({});
}); });
it("opens a thread it has never seen in one request", async () => {
const { calls, requests } = server(1, 3);
const got = await useMail.getState().loadThread("te0");
expect(requests()).toBe(1);
expect(calls.map(([n]) => n)).toEqual(["Thread/get", "Email/get"]);
expect(calls[1]![1].fetchHTMLBodyValues).toBe(true);
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0", "e0m1"]);
expect(useMail.getState().fullIds).toEqual({ e0: true, e0m0: true, e0m1: true });
expect(useMail.getState().threads.te0?.emailIds).toEqual(["e0", "e0m0", "e0m1"]);
});
it("splits a thread longer than one Email/get may carry", async () => { it("splits a thread longer than one Email/get may carry", async () => {
const { calls } = server(1, 1200); const { calls } = server(1, 1200);
known("te0", ["e0", ...Array.from({ length: 1199 }, (_, j) => `e0m${j}`)]);
const got = await useMail.getState().loadThread("te0"); const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200); expect(got).toHaveLength(1200);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true); expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
}); });
it("falls back to parts when a thread it has never seen is too long for one request", async () => {
const { calls } = server(1, 1200);
const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200);
// The refused call, then the members in parts the server takes.
expect(getSizes(calls)).toEqual([1200, 500, 500, 200]);
});
});
describe("prefetchThread", () => {
it("is the request a later open waits for", async () => {
const { requests } = server(1, 2);
useMail.getState().prefetchThread("te0");
const got = await useMail.getState().loadThread("te0");
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0"]);
// The open waits for the prefetch and asks for nothing more.
expect(requests()).toBe(1);
});
it("does nothing for a conversation already held in full", async () => {
const { requests } = server(1, 1);
useMail.setState({ threads: { te0: { id: "te0", emailIds: ["e0"] } }, emails: { e0: { id: "e0" } } as never, fullIds: { e0: true } });
useMail.getState().prefetchThread("te0");
await Promise.resolve();
expect(requests()).toBe(0);
});
it("asks once however often it is asked", async () => {
const { requests } = server(1, 1);
useMail.getState().prefetchThread("te0");
useMail.getState().prefetchThread("te0");
useMail.getState().prefetchThread("te0");
await vi.waitFor(() => expect(useMail.getState().fullIds.e0).toBe(true));
expect(requests()).toBe(1);
});
});
describe("going back to a folder", () => {
const folder = (mailboxId: string) => ({ key: "", filter: { inMailbox: mailboxId }, sort: [], collapseThreads: false, mailboxId });
it("shows its last list while the query is on its way, less what has left it", async () => {
server(3);
await useMail.getState().query(folder(INBOX));
expect(useMail.getState().list!.ids).toEqual(["e0", "e1", "e2"]);
await useMail.getState().query(folder("mbOther"));
// e1 is moved away meanwhile.
useMail.setState((s) => ({ emails: { ...s.emails, e1: { ...s.emails.e1!, mailboxIds: { mbOther: true } } } }));
const back = useMail.getState().query(folder(INBOX));
const shown = useMail.getState().list!;
expect(shown.loading).toBe(true);
expect(shown.ids).toEqual(["e0", "e2"]);
expect(shown.total).toBe(2);
await back;
expect(useMail.getState().list!.loading).toBe(false);
});
it("starts empty for a folder not read before, and for a search", async () => {
server(3);
await useMail.getState().query(folder(INBOX));
void useMail.getState().query(folder("mbNever"));
expect(useMail.getState().list!.ids).toEqual([]);
const search = { filter: { inMailbox: INBOX, text: "x" }, sort: [], collapseThreads: false, mailboxId: INBOX };
await useMail.getState().query(search as never);
await useMail.getState().query(folder(INBOX));
void useMail.getState().query(search as never);
expect(useMail.getState().list!.ids).toEqual([]);
});
}); });
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useMail } from "@/store/mail";
vi.mock("@/lib/notify/notify", () => ({ playNewMailSound: vi.fn(), showNotification: vi.fn() }));
/**
* What a push costs. On a slow link every round trip is felt, and a push
* arrives after almost everything the reader does -- marking one message read
* is echoed back as a change.
*/
const INBOX = "mbInbox";
type Call = [string, Record<string, unknown>, string];
const email = (id: string, keywords: Record<string, boolean> = {}) => ({ id, threadId: `t${id}`, mailboxIds: { [INBOX]: true }, keywords, receivedAt: "2026-09-16T00:00:00Z" });
function server(changes: { created?: string[]; updated?: string[]; destroyed?: string[] } | "cannotCalculateChanges") {
const requests: Call[][] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
requests.push(methodCalls);
const responses: Call[] = [];
for (const [name, args, id] of methodCalls) {
const ref = args["#ids"] as { resultOf: string; path: string } | undefined;
if (name === "Email/changes") {
if (changes === "cannotCalculateChanges") responses.push(["error", { type: "cannotCalculateChanges" }, id]);
else responses.push([name, { accountId: "a1", oldState: "s1", newState: "s2", hasMoreChanges: false, created: changes.created ?? [], updated: changes.updated ?? [], destroyed: changes.destroyed ?? [] }, id]);
} else if (name === "Email/get") {
const from = ref ? responses.find((r) => r[2] === ref.resultOf)?.[1] : undefined;
const ids = ref ? ((from?.[ref.path.slice(1)] as string[] | undefined) ?? []) : (args.ids as string[]);
responses.push([name, { accountId: "a1", state: "s2", list: ids.map((x) => email(x, { $seen: true })), notFound: [] }, id]);
} else if (name === "Mailbox/get") {
responses.push([name, { accountId: "a1", state: "m2", list: [{ id: INBOX, role: "inbox", name: "Inbox" }], notFound: [] }, id]);
} else {
responses.push([name, { accountId: "a1", state: "s2", list: [], ids: [], total: 0, notFound: [] }, id]);
}
}
return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "x" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { requests, names: () => requests.map((r) => r.map(([n]) => n)) };
}
beforeEach(() => {
client.session = { capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: {}, primaryAccounts: {}, state: "x" } as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
list: null,
emails: { e1: email("e1"), e2: email("e2") } as never,
fullIds: {},
threads: {},
emailState: "s1",
openThreadId: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("a pushed mail change", () => {
it("fetches the changes and what they name in one request, beside one Mailbox/get", async () => {
const { names } = server({ created: ["e9"], updated: ["e1", "e5"], destroyed: ["e2"] });
await useMail.getState().applyChanges(new Set(["Email", "Mailbox", "Thread"]));
await vi.waitFor(() => expect(useMail.getState().mailboxState).toBe("m2"));
const all = names();
expect(all.find((r) => r.includes("Email/changes"))).toEqual(["Email/changes", "Email/get", "Email/get"]);
expect(all.flat().filter((n) => n === "Mailbox/get")).toHaveLength(1);
// Nothing named by the changes was asked for again.
expect(all.flat().filter((n) => n === "Email/get")).toHaveLength(2);
const { emails, emailState } = useMail.getState();
expect(emailState).toBe("s2");
expect(emails.e1?.keywords).toEqual({ $seen: true });
expect(emails.e2).toBeUndefined();
expect(emails.e9).toBeDefined();
// An update to a message not held is not taken in.
expect(emails.e5).toBeUndefined();
});
it("forgets its state when the server cannot say what changed", async () => {
server("cannotCalculateChanges");
await useMail.getState().applyChanges(new Set(["Email"]));
expect(useMail.getState().emailState).toBeNull();
});
});
describe("a new session state", () => {
it("is announced once, however many replies carry it", async () => {
server({});
client.session = { ...client.session!, state: "old" };
const seen = vi.fn();
const off = client.onSessionState(seen);
await Promise.all([client.request([["Core/echo", {}, "a"]]), client.request([["Core/echo", {}, "b"]]), client.request([["Core/echo", {}, "c"]])]);
off();
expect(seen).toHaveBeenCalledTimes(1);
expect(seen).toHaveBeenCalledWith("x");
});
});
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { buildSnapshot, useMail } from "@/store/mail";
import { useSession } from "@/store/session";
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
/**
* Starting from what a trusted device kept: the folder list and the first page
* of recent folders, applied once the server has confirmed the session. On a
* distant link each of those was a round trip before the inbox could show.
*/
const INBOX = "mbInbox";
const session = (remember: boolean) =>
({
capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: { a1: { name: "me", isPersonal: true, isReadOnly: false, accountCapabilities: { [CAP.mail]: {} } } },
primaryAccounts: { [CAP.mail]: "a1" },
state: "s",
username: "me",
apiUrl: "",
downloadUrl: "",
uploadUrl: "",
eventSourceUrl: "",
ihasmail: { remember, sessionId: "public-id" },
}) as unknown as JmapSession;
const email = (id: string) => ({
id,
threadId: `t${id}`,
mailboxIds: { [INBOX]: true },
keywords: {},
receivedAt: "2026-09-16T00:00:00Z",
subject: `Subject ${id}`,
preview: "p",
bodyValues: { "1": { value: "secret body" } },
});
const inboxQuery = { key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX };
/** The key the store gives the inbox list, learned by asking for it. */
function inboxKey(): string {
useMail.getState().setAccount("probe");
void useMail.getState().query(inboxQuery);
const key = useMail.getState().list!.key;
useMail.getState().setAccount(null);
return key;
}
function signedInWithList() {
const key = inboxKey();
useMail.getState().setAccount("a1");
useMail.setState({
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
mailboxesLoaded: true,
emails: { e1: email("e1"), e2: email("e2") } as never,
list: { key, filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX, ids: ["e1", "e2"], total: 7, queryState: "q", loading: false, loadingMore: false, error: null, exhausted: false },
});
}
let pending: ((v: Response) => void) | null;
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
pending = null;
// The session request stays unanswered unless a test answers it.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((resolve) => { pending = resolve; })));
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
setDeviceTrusted(false);
localStorage.clear();
});
describe("the kept mail snapshot", () => {
it("holds folders and list rows without bodies", () => {
signedInWithList();
const snap = buildSnapshot(useMail.getState())!;
expect(snap.mailboxes.map((m) => m.id)).toEqual([INBOX]);
expect(snap.lists).toEqual([{ key: inboxKey(), ids: ["e1", "e2"], total: 7 }]);
expect(snap.emails.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(JSON.stringify(snap)).not.toContain("secret body");
});
it("is not made before the server's folder list has arrived", () => {
signedInWithList();
useMail.setState({ mailboxesLoaded: false });
expect(buildSnapshot(useMail.getState())).toBeNull();
});
it("paints the folders and the inbox on the next start of a trusted device", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
// A change to the list schedules the save.
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toContain('"e1"');
// Next start.
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
const s = useMail.getState();
expect(s.mailboxesCached).toBe(true);
expect(s.mailboxesLoaded).toBe(false);
expect(s.mailboxes[INBOX]?.name).toBe("Inbox");
void s.query(inboxQuery);
expect(useMail.getState().list).toMatchObject({ ids: ["e1", "e2"], total: 8, loading: true });
});
it("is neither written nor read on a device not marked as the reader's own", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
setDeviceTrusted(false);
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().mailboxes).toEqual({});
localStorage.clear();
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("is gone after signing out, and a save scheduled before it does not bring it back", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
clearSignedInData();
useSession.setState({ status: "anonymous", accountId: null });
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("belongs to one account", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.getState().setAccount("a2");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().emails).toEqual({});
});
});
describe("before the server has confirmed the session", () => {
it("shows nothing kept: the spinner stays until the answer, and the kept folders arrive with it", async () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
// Next start.
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
const boot = useSession.getState().bootstrap();
expect(useSession.getState().status).toBe("loading");
expect(useMail.getState().accountId).toBeNull();
expect(useMail.getState().mailboxes).toEqual({});
expect(useMail.getState().emails).toEqual({});
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(useSession.getState().status).toBe("authenticated");
expect(useMail.getState().mailboxesCached).toBe(true);
expect(useMail.getState().mailboxes[INBOX]?.name).toBe("Inbox");
});
it("keeps no session of its own", async () => {
setDeviceTrusted(true);
const boot = useSession.getState().bootstrap();
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(localStorage.getItem("ihasmail:session")).toBeNull();
});
});
+251 -21
View File
@@ -19,6 +19,7 @@ import type {
VacationResponse, VacationResponse,
ChangesResponse, ChangesResponse,
Invocation, Invocation,
MethodError,
} from "@/jmap/types"; } from "@/jmap/types";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { settings, useSettings } from "../settings"; import { settings, useSettings } from "../settings";
@@ -26,6 +27,7 @@ import { useSession } from "../session";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage";
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
import { type ListQuery, type MailState } from "./types"; import { type ListQuery, type MailState } from "./types";
import { playNewMailSound, showNotification } from "@/lib/notify/notify"; import { playNewMailSound, showNotification } from "@/lib/notify/notify";
@@ -78,6 +80,7 @@ export const useMail = create<MailState>((set, get) => ({
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
mailboxesCached: false,
emails: {}, emails: {},
fullIds: {}, fullIds: {},
emailState: null, emailState: null,
@@ -101,11 +104,13 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) { setAccount(accountId) {
if (accountId === get().accountId) return; if (accountId === get().accountId) return;
resetBodyOrder(); resetBodyOrder();
snapshots.clear();
set({ set({
accountId, accountId,
mailboxes: {}, mailboxes: {},
mailboxState: null, mailboxState: null,
mailboxesLoaded: false, mailboxesLoaded: false,
mailboxesCached: false,
emails: {}, emails: {},
fullIds: {}, fullIds: {},
emailState: null, emailState: null,
@@ -119,6 +124,7 @@ export const useMail = create<MailState>((set, get) => ({
anchorId: null, anchorId: null,
lastSeenInboxEmailIds: null, lastSeenInboxEmailIds: null,
}); });
if (accountId) restoreSnapshot(accountId);
}, },
async loadMailboxes() { async loadMailboxes() {
@@ -127,7 +133,7 @@ export const useMail = create<MailState>((set, get) => ({
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS }); const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS });
const mailboxes: Record<Id, Mailbox> = {}; const mailboxes: Record<Id, Mailbox> = {};
for (const m of res.list) mailboxes[m.id] = m; for (const m of res.list) mailboxes[m.id] = m;
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true }); set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true, mailboxesCached: false });
// Label counts move for the same reasons folder counts do -- something was // Label counts move for the same reasons folder counts do -- something was
// read, moved or deleted -- so they are refreshed on the same beat rather // read, moved or deleted -- so they are refreshed on the same beat rather
// than on a timer of their own. Not awaited: the folder tree should not // than on a timer of their own. Not awaited: the folder tree should not
@@ -169,8 +175,10 @@ export const useMail = create<MailState>((set, get) => ({
void get().refreshList(); void get().refreshList();
return; return;
} }
if (cur && cur.key !== key) keepSnapshot(cur);
const shown = reuse ? { ids: cur.ids, total: cur.total } : snapshotFor(key, q.filter, get().emails);
set({ set({
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false }, list: { ...q, key, ids: shown.ids, total: shown.total, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
selected: {}, selected: {},
selectedAll: false, selectedAll: false,
anchorId: null, anchorId: null,
@@ -290,8 +298,7 @@ export const useMail = create<MailState>((set, get) => ({
* held in full needs nothing more. getEmails also splits the fetch to * held in full needs nothing more. getEmails also splits the fetch to
* `maxObjectsInGet`, which a long thread could exceed. * `maxObjectsInGet`, which a long thread could exceed.
*/ */
const res = await client.call<GetResponse<Thread>>("Thread/get", { accountId, ids: [threadId] }); const thread = await fetchThread(accountId, threadId, get);
const thread = res.list[0];
if (!thread) { if (!thread) {
set((s) => { set((s) => {
const { [threadId]: _drop, ...rest } = s.loadingThreads; const { [threadId]: _drop, ...rest } = s.loadingThreads;
@@ -299,7 +306,6 @@ export const useMail = create<MailState>((set, get) => ({
}); });
return []; return [];
} }
await get().getEmails(thread.emailIds, true);
set((s) => { set((s) => {
const { [threadId]: _drop, ...rest } = s.loadingThreads; const { [threadId]: _drop, ...rest } = s.loadingThreads;
return { threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest }; return { threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
@@ -314,6 +320,21 @@ export const useMail = create<MailState>((set, get) => ({
} }
}, },
prefetchThread(threadId) {
const { accountId, threads, fullIds } = get();
if (!accountId || prefetched.has(threadId)) return;
const known = threads[threadId];
if (known && known.emailIds.every((id) => fullIds[id])) return;
const run = fetchThread(accountId, threadId, get)
.then((thread) => {
if (thread) set((s) => ({ threads: { ...s.threads, [threadId]: thread } }));
return thread;
})
.catch(() => null)
.finally(() => prefetched.delete(threadId));
prefetched.set(threadId, run);
},
threadEmails(threadId) { threadEmails(threadId) {
const { threads, emails } = get(); const { threads, emails } = get();
const t = threads[threadId]; const t = threads[threadId];
@@ -940,7 +961,11 @@ export const useMail = create<MailState>((set, get) => ({
async applyChanges(types) { async applyChanges(types) {
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return; if (!accountId) return;
if (types.has("Mailbox")) void get().loadMailboxes(); /*
* Folder counts move with mail, so one Mailbox/get serves both kinds of
* change. It goes out now, beside Email/changes, rather than again after.
*/
if (types.has("Mailbox") || types.has("Email")) void get().loadMailboxes();
if (types.has("Email")) { if (types.has("Email")) {
const state = get().emailState; const state = get().emailState;
if (state) { if (state) {
@@ -950,12 +975,25 @@ export const useMail = create<MailState>((set, get) => ({
const updated = new Set<Id>(); const updated = new Set<Id>();
const created = new Set<Id>(); const created = new Set<Id>();
const destroyed = new Set<Id>(); const destroyed = new Set<Id>();
// Page through Email/changes. const fetched: Email[] = [];
/*
* Page through Email/changes, each page in one request with the
* list-level properties of what it names. Asking for those after the
* ids came back cost a second round trip on every push.
*/
const maxChanges = Math.min(500, client.maxObjectsInGet);
while (guard++ < 10) { while (guard++ < 10) {
const ch = await client.call<ChangesResponse>("Email/changes", { accountId, sinceState: since, maxChanges: 500 }); const ref = (path: string) => ({ resultOf: "c", name: "Email/changes", path });
const res = await client.chain([
["Email/changes", { accountId, sinceState: since, maxChanges }, "c"],
["Email/get", { accountId, "#ids": ref("/updated"), properties: LIST_PROPS }, "u"],
["Email/get", { accountId, "#ids": ref("/created"), properties: LIST_PROPS }, "n"],
]);
const ch = res.get("c")![0] as unknown as ChangesResponse;
ch.created.forEach((id) => created.add(id)); ch.created.forEach((id) => created.add(id));
ch.updated.forEach((id) => updated.add(id)); ch.updated.forEach((id) => updated.add(id));
ch.destroyed.forEach((id) => destroyed.add(id)); ch.destroyed.forEach((id) => destroyed.add(id));
for (const key of ["u", "n"]) fetched.push(...((res.get(key)?.[0] as unknown as GetResponse<Email> | undefined)?.list ?? []));
since = ch.newState; since = ch.newState;
if (!ch.hasMoreChanges) break; if (!ch.hasMoreChanges) break;
} }
@@ -966,6 +1004,12 @@ export const useMail = create<MailState>((set, get) => ({
delete next[id]; delete next[id];
delete nextFull[id]; delete nextFull[id];
} }
// An update merges over what is held; a message not held stays out,
// except new mail, which the notice below and the list both want.
for (const e of fetched) {
if (destroyed.has(e.id)) continue;
if (next[e.id] || created.has(e.id)) next[e.id] = mergeEmail(next[e.id], e);
}
/* /*
* The full copy of an updated email is deliberately kept. * The full copy of an updated email is deliberately kept.
* *
@@ -989,18 +1033,6 @@ export const useMail = create<MailState>((set, get) => ({
*/ */
return { emails: next, fullIds: nextFull, emailState: since }; return { emails: next, fullIds: nextFull, emailState: since };
}); });
// Refresh the list-level props of updated/cached emails.
const cached = [...updated].filter((id) => get().emails[id]);
if (cached.length) {
const results = await Promise.all(
chunk(cached, client.maxObjectsInGet).map((part) => client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: LIST_PROPS })),
);
set((s) => {
const next = { ...s.emails };
for (const r of results) for (const e of r.list) next[e.id] = mergeEmail(next[e.id], e);
return { emails: next };
});
}
if (created.size) await notifyNewMail([...created], get); if (created.size) await notifyNewMail([...created], get);
} catch (err) { } catch (err) {
if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") { if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") {
@@ -1009,7 +1041,6 @@ export const useMail = create<MailState>((set, get) => ({
} }
} }
void get().refreshList(); void get().refreshList();
void get().loadMailboxes();
} }
if (types.has("Thread") || types.has("Email")) { if (types.has("Thread") || types.has("Email")) {
const open = get().openThreadId; const open = get().openThreadId;
@@ -1114,6 +1145,189 @@ export function resetBodyOrder(): void {
bodyOrder.length = 0; bodyOrder.length = 0;
} }
/*
* Opening a conversation in one round trip.
*
* It used to take two: Thread/get, then the bodies once the ids came back --
* half a second on a 250 ms link before anything showed. When the list has
* already fetched the thread (it has, in conversation view), the missing
* bodies are asked for in the same tick as the Thread/get, and the client
* sends both in one request. When it has not, the two are chained with a
* back-reference, which is also one request. Either way a member the list did
* not know about is fetched afterwards, which is rare.
*
* Loads of the same thread share one request: a conversation fetched ahead of
* the click (`prefetchThread`) is the one the click then waits for, and what
* it brought back is not asked for again.
*/
const prefetched = new Map<Id, Promise<Thread | null>>();
async function fetchThread(accountId: Id, threadId: Id, get: () => MailState): Promise<Thread | null> {
const ahead = prefetched.get(threadId);
if (ahead) {
const thread = await ahead;
if (thread && thread.emailIds.every((id) => get().fullIds[id])) return thread;
}
const { threads, fullIds } = get();
const known = threads[threadId];
let thread: Thread | undefined;
if (known) {
const missing = known.emailIds.filter((id) => !fullIds[id]);
const [res] = await Promise.all([
client.call<GetResponse<Thread>>("Thread/get", { accountId, ids: [threadId] }),
missing.length ? get().getEmails(missing, true) : Promise.resolve([]),
]);
thread = res.list[0];
} else {
const res = await client.chain([
["Thread/get", { accountId, ids: [threadId] }, "t"],
[
"Email/get",
{
accountId,
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
properties: FULL_PROPS,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: 2 * 1024 * 1024,
bodyProperties: BODY_PROPS,
},
"e",
],
], { allowErrors: true });
const threadRes = res.get("t")?.[0];
if (threadRes && "__error" in threadRes) throw new JmapMethodError("Thread/get", threadRes.__error as MethodError);
thread = (threadRes as unknown as GetResponse<Thread> | undefined)?.list[0];
// A thread longer than one Email/get may carry is refused whole; the members are fetched in parts below.
const got = (res.get("e")?.[0] as unknown as Partial<GetResponse<Email>> | undefined)?.list ?? [];
if (got.length) {
useMail.setState((s) => {
const emails = { ...s.emails };
const full = { ...s.fullIds };
for (const e of got) {
emails[e.id] = mergeEmail(emails[e.id], e);
full[e.id] = true;
}
return { emails, fullIds: full };
});
touchBodies(got.map((e) => e.id));
useMail.setState((s) => releaseBodies(s));
}
}
if (!thread) return null;
const late = thread.emailIds.filter((id) => !get().fullIds[id]);
if (late.length) await get().getEmails(late, true);
return thread;
}
/*
* The last few folders' lists, shown again while their query is on its way.
* Going back to a folder read a moment ago otherwise blanks the list for a
* round trip. Only plain folder views are kept: a message that has since left
* the folder is dropped here, and anything else that changed is corrected by
* the query a round trip later.
*/
const SNAPSHOTS_KEPT = 12;
const SNAPSHOT_IDS = 200;
const snapshots = new Map<string, { ids: Id[]; total: number }>();
function folderOf(filter: EmailFilter): Id | null {
const keys = Object.keys(filter);
return keys.length === 1 && "inMailbox" in filter && typeof filter.inMailbox === "string" ? filter.inMailbox : null;
}
function keepSnapshot(list: NonNullable<MailState["list"]>): void {
if (list.loading || list.error || !folderOf(list.filter)) return;
snapshots.delete(list.key);
snapshots.set(list.key, { ids: list.ids.slice(0, SNAPSHOT_IDS), total: list.total });
while (snapshots.size > SNAPSHOTS_KEPT) snapshots.delete(snapshots.keys().next().value!);
}
function snapshotFor(key: string, filter: EmailFilter, emails: Record<Id, Email>): { ids: Id[]; total: number } {
const snap = snapshots.get(key);
const folder = folderOf(filter);
if (!snap || !folder) return { ids: [], total: 0 };
const ids = snap.ids.filter((id) => emails[id]?.mailboxIds[folder]);
return { ids, total: snap.total - (snap.ids.length - ids.length) };
}
/*
* What a device marked as the reader's own keeps between visits.
*
* Opening the app used to wait on the folder list before it could ask for a
* folder, and on that before anything showed: on a distant link, a second or
* so of skeleton on every start. A trusted device now keeps the folder list
* and the first page of the last few folders, list properties only -- no
* bodies -- and starts from them: the folders and the inbox paint as soon as
* the server has confirmed the session, and the query for the open folder goes
* out then without waiting for the folder list, which corrects both a round
* trip later.
*
* Never sooner. This is applied from `setAccount`, which runs only once the
* session is confirmed, so a session that has ended shows the spinner and then
* the sign-in form, and none of this in between.
*
* It is written through the same gated storage as the settings cache: nothing
* is kept on a device not marked as the reader's own, nothing is read there
* either, and signing out clears it with everything else.
*/
const SNAPSHOT_KEY = "mail-snapshot";
const SNAPSHOT_LISTS = 4;
const SNAPSHOT_ROWS = 50;
const SNAPSHOT_MAX_CHARS = 400_000;
export interface MailSnapshot {
v: 1;
accountId: Id;
mailboxes: Mailbox[];
lists: { key: string; ids: Id[]; total: number }[];
emails: Email[];
}
export function buildSnapshot(s: MailState): MailSnapshot | null {
if (!s.accountId || !s.mailboxesLoaded) return null;
const lists: MailSnapshot["lists"] = [];
const cur = s.list;
if (cur && !cur.loading && !cur.error && folderOf(cur.filter)) lists.push({ key: cur.key, ids: cur.ids, total: cur.total });
for (const [key, snap] of [...snapshots].reverse()) {
if (lists.length >= SNAPSHOT_LISTS) break;
if (!lists.some((l) => l.key === key)) lists.push({ key, ...snap });
}
const ids = new Set<Id>();
const kept = lists.map((l) => {
const rows = l.ids.filter((id) => s.emails[id]).slice(0, SNAPSHOT_ROWS);
rows.forEach((id) => ids.add(id));
return { key: l.key, ids: rows, total: l.total };
});
const emails = [...ids].map((id) => Object.fromEntries(Object.entries(s.emails[id]!).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email);
return { v: 1, accountId: s.accountId, mailboxes: Object.values(s.mailboxes), lists: kept, emails };
}
let snapshotTimer: ReturnType<typeof setTimeout> | null = null;
function saveSnapshot(): void {
if (snapshotTimer) clearTimeout(snapshotTimer);
snapshotTimer = null;
if (!isDeviceTrusted() || useSession.getState().status !== "authenticated") return;
const s = useMail.getState();
if (s.accountId !== useSession.getState().accountId) return;
const snap = buildSnapshot(s);
if (!snap) return;
if (JSON.stringify(snap).length > SNAPSHOT_MAX_CHARS) return;
saveJson(SNAPSHOT_KEY, snap);
}
function restoreSnapshot(accountId: Id): void {
const snap = loadRaw<MailSnapshot | null>(SNAPSHOT_KEY, null);
if (!snap || snap.v !== 1 || snap.accountId !== accountId || !Array.isArray(snap.mailboxes) || !snap.mailboxes.length) return;
const mailboxes: Record<Id, Mailbox> = {};
for (const m of snap.mailboxes) mailboxes[m.id] = m;
const emails: Record<Id, Email> = {};
for (const e of snap.emails ?? []) emails[e.id] = e;
for (const l of snap.lists ?? []) snapshots.set(l.key, { ids: l.ids, total: l.total });
useMail.setState({ mailboxes, mailboxesCached: true, emails });
}
let sortRefused = false; let sortRefused = false;
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
@@ -1380,3 +1594,19 @@ async function followFolders(before: FolderRef[]): Promise<void> {
toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message })); toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message }));
} }
} }
/*
* Kept a few seconds after the folders or the list last changed, and when the
* page is being put away, which is the last chance a closing tab gets.
*/
useMail.subscribe((s, prev) => {
if (s.mailboxes === prev.mailboxes && s.list === prev.list) return;
if (!isDeviceTrusted()) return;
if (snapshotTimer) clearTimeout(snapshotTimer);
snapshotTimer = setTimeout(saveSnapshot, 3000);
});
if (typeof window !== "undefined") {
window.addEventListener("pagehide", () => {
if (snapshotTimer) saveSnapshot();
});
}
+4
View File
@@ -36,6 +36,8 @@ export interface MailState {
mailboxes: Record<Id, Mailbox>; mailboxes: Record<Id, Mailbox>;
mailboxState: string | null; mailboxState: string | null;
mailboxesLoaded: boolean; mailboxesLoaded: boolean;
/** The folder list shown is the copy this device kept, not yet confirmed by the server. */
mailboxesCached: boolean;
emails: Record<Id, Email>; emails: Record<Id, Email>;
fullIds: Record<Id, true>; fullIds: Record<Id, true>;
emailState: string | null; emailState: string | null;
@@ -71,6 +73,8 @@ export interface MailState {
getEmails(ids: Id[], full?: boolean): Promise<Email[]>; getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
loadThread(threadId: Id): Promise<Email[]>; loadThread(threadId: Id): Promise<Email[]>;
/** Start loading a conversation that is likely to be opened next; quiet, and shared with a later loadThread. */
prefetchThread(threadId: Id): void;
threadEmails(threadId: Id): Email[]; threadEmails(threadId: Id): Email[];
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[]; threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
+9 -1
View File
@@ -32,6 +32,8 @@ interface SessionState {
ownAccountFor(cap: string): Id | null; ownAccountFor(cap: string): Id | null;
} }
let refreshing: Promise<void> | null = null;
export const useSession = create<SessionState>((set, get) => ({ export const useSession = create<SessionState>((set, get) => ({
status: "loading", status: "loading",
session: null, session: null,
@@ -100,7 +102,9 @@ export const useSession = create<SessionState>((set, get) => ({
set({ status: "anonymous", session: null, accountId: null }); set({ status: "anonymous", session: null, accountId: null });
}, },
async refresh() { refresh() {
// Callers arriving while a refresh is on its way share it.
refreshing ??= (async () => {
try { try {
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1"); const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s; client.session = s;
@@ -108,7 +112,11 @@ export const useSession = create<SessionState>((set, get) => ({
set({ session: s }); set({ session: s });
} catch { } catch {
/* ignore */ /* ignore */
} finally {
refreshing = null;
} }
})();
return refreshing;
}, },
setAccount(id) { setAccount(id) {
+4
View File
@@ -2418,3 +2418,7 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; } .ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; }
.ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; } .ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; }
.ev-block:hover .ev-resize::after { opacity: .5; } .ev-block:hover .ev-resize::after { opacity: .5; }
/* What arrived from the operating system's share sheet, shown before it is opened. */
.share-summary { margin: 0 0 8px; padding-left: 1ex; border-left: 2px solid var(--border-strong); color: var(--fg-muted); overflow-wrap: anywhere; }
.share-summary-files { margin: 0 0 8px; padding-left: 1.2em; color: var(--fg-muted); overflow-wrap: anywhere; }
+2 -1
View File
@@ -185,7 +185,8 @@ export function ConfirmHost() {
) )
} }
> >
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>} {/* A paragraph for text; a block for anything with blocks of its own in it. */}
{req.message && (typeof req.message === "string" ? <p style={{ marginTop: 0 }}>{req.message}</p> : <div style={{ marginTop: 0 }}>{req.message}</div>)}
{req.kind === "choice" && ( {req.kind === "choice" && (
<div className="dialog-choices"> <div className="dialog-choices">
{req.choices?.map((c) => ( {req.choices?.map((c) => (
+4 -2
View File
@@ -17,6 +17,7 @@ import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { MailboxPicker } from "./mail/MailboxPicker"; import { MailboxPicker } from "./mail/MailboxPicker";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { collectShare } from "@/lib/shareTarget"; import { collectShare } from "@/lib/shareTarget";
import { offerShare } from "./ShareOffer";
import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { TranslateBoundary } from "@/ui/TranslateBoundary";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { hasAdministration } from "@/lib/admin/adminAccess"; import { hasAdministration } from "@/lib/admin/adminAccess";
@@ -119,11 +120,12 @@ export function AppShell({ children }: { children: ReactNode }) {
* `addFiles` uploads as it goes, and there is nothing to upload to until the * `addFiles` uploads as it goes, and there is nothing to upload to until the
* session is in place. AppShell only exists once there is one. * session is in place. AppShell only exists once there is one.
*/ */
// Asked about first, not opened straight away: see `offerShare`.
useEffect(() => { useEffect(() => {
void collectShare().then((share) => { void collectShare().then(async (share) => {
if (!share) return; if (!share) return;
openShare(share);
if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true }); if (new URLSearchParams(window.location.search).has("share")) navigate("/mail", { replace: true });
await offerShare(share, openShare);
}); });
}, [openShare, navigate]); }, [openShare, navigate]);
+45
View File
@@ -0,0 +1,45 @@
import { shareSummary, type SharedContent } from "@/lib/shareTarget";
import { confirmDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
/**
* Ask before a share becomes a message.
*
* The share address takes a plain form POST, so any website can send one, and
* the page cannot tell that from a share the reader made. Nothing would be
* sent without them pressing Send, but a composer that appears full of
* somebody else's text and files is still something to be asked about first.
*/
export async function offerShare(share: SharedContent, open: (share: SharedContent) => unknown): Promise<boolean> {
const yes = await confirmDialog({
title: t("Start a new message with what was shared?"),
message: <ShareSummary share={share} />,
confirmLabel: t("Start a message"),
cancelLabel: t("Discard"),
});
if (yes) open(share);
return yes;
}
/** What arrived, so the reader can tell whether it is theirs. */
function ShareSummary({ share }: { share: SharedContent }) {
const { title, preview, files } = shareSummary(share);
return (
<div>
{(title || preview) && (
<blockquote className="share-summary notranslate" translate="no">
{title && <strong>{title}</strong>}
{title && preview && <br />}
{preview}
</blockquote>
)}
{files.length > 0 && (
<ul className="share-summary-files notranslate" translate="no">
{files.slice(0, 5).map((name, i) => <li key={`${name}-${i}`}>{name}</li>)}
{files.length > 5 && <li></li>}
</ul>
)}
<p>{t("Something was shared with ihasmail. Nothing is sent until you choose Send. If you didn't just share this, discard it.")}</p>
</div>
);
}
@@ -0,0 +1,69 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmHost } from "@/ui/dialog";
import { offerShare } from "../ShareOffer";
import type { SharedContent } from "@/lib/shareTarget";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* A share becomes a message only when the reader says so. The share address
* takes a plain form POST, which any website can make.
*/
const share: SharedContent = {
title: "Quarterly figures",
text: "Have a look at these before Friday",
url: "https://example.com/q3",
files: [new File(["x"], "q3.xlsx"), new File(["y"], "notes.txt")],
};
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<ConfirmHost />));
});
afterEach(() => {
act(() => root.unmount());
host.remove();
document.body.innerHTML = "";
});
const button = (label: string) => [...document.querySelectorAll("button")].find((b) => b.textContent?.trim() === label);
describe("offering a share", () => {
it("shows what arrived before anything is opened", async () => {
const open = vi.fn();
let pending!: Promise<boolean>;
await act(async () => {
pending = offerShare(share, open);
});
const text = document.body.textContent ?? "";
expect(text).toContain("Start a new message with what was shared?");
expect(text).toContain("Quarterly figures");
expect(text).toContain("Have a look at these before Friday https://example.com/q3");
expect(text).toContain("q3.xlsx");
expect(text).toContain("notes.txt");
expect(open).not.toHaveBeenCalled();
await act(async () => button("Start a message")!.click());
expect(await pending).toBe(true);
expect(open).toHaveBeenCalledWith(share);
});
it("opens nothing when discarded", async () => {
const open = vi.fn();
let pending!: Promise<boolean>;
await act(async () => {
pending = offerShare(share, open);
});
await act(async () => button("Discard")!.click());
expect(await pending).toBe(false);
expect(open).not.toHaveBeenCalled();
});
});
+23 -2
View File
@@ -27,6 +27,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const searchStr = useSearch(); const searchStr = useSearch();
const mailboxes = useMail((s) => s.mailboxes); const mailboxes = useMail((s) => s.mailboxes);
const mailboxesLoaded = useMail((s) => s.mailboxesLoaded); const mailboxesLoaded = useMail((s) => s.mailboxesLoaded);
// Enough to ask for a folder: the kept copy of the folder list will do, and
// staying true as the server's copy replaces it keeps the query from repeating.
const mailboxesKnown = useMail((s) => s.mailboxesLoaded || s.mailboxesCached);
const inboxId = useMail((s) => s.roleId("inbox")); const inboxId = useMail((s) => s.roleId("inbox"));
const query = useMail((s) => s.query); const query = useMail((s) => s.query);
const list = useMail((s) => s.list); const list = useMail((s) => s.list);
@@ -114,8 +117,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
}, [search, q, mailboxId, folderShape, settings.conversationMode, scheduledId]); }, [search, q, mailboxId, folderShape, settings.conversationMode, scheduledId]);
useEffect(() => { useEffect(() => {
if (listQuery && mailboxesLoaded) void query(listQuery); if (listQuery && mailboxesKnown) void query(listQuery);
}, [listQuery, query, mailboxesLoaded]); }, [listQuery, query, mailboxesKnown]);
// Nothing moves a message out of Scheduled when its hold expires, so settle // Nothing moves a message out of Scheduled when its hold expires, so settle
// the folder up on the way in: sent messages to Sent, canceled ones back to // the folder up on the way in: sent messages to Sent, canceled ones back to
@@ -183,6 +186,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
return -1; return -1;
}, [ids, focusId, openMessageId, threadId, rowThreadId]); }, [ids, focusId, openMessageId, threadId, rowThreadId]);
/*
* Reading down a folder usually means the next row is next. Once the open
* conversation has had its turn, the one below starts loading while the
* reader reads, so moving on waits on nothing.
*/
const nextRowId = threadId && currentRowIndex >= 0 ? ids[currentRowIndex + 1] : undefined;
const nextThreadId = nextRowId ? rowThreadId(nextRowId) : undefined;
useEffect(() => {
if (!nextThreadId || nextThreadId === threadId) return;
const start = () => useMail.getState().prefetchThread(nextThreadId);
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(start, { timeout: 2000 });
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(start, 500);
return () => window.clearTimeout(handle);
}, [nextThreadId, threadId]);
/** Email ids affected by an action on rows (selection or focused/open row). */ /** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback( const targetIds = useCallback(
async (rowIds?: Id[]): Promise<Id[]> => { async (rowIds?: Id[]): Promise<Id[]> => {
+1 -1
View File
@@ -38,7 +38,7 @@ const FOLDER_MIME = "application/x-ihasmail-folder";
export function MailboxTree() { export function MailboxTree() {
const mailboxes = useMail((s) => s.mailboxes); const mailboxes = useMail((s) => s.mailboxes);
const loaded = useMail((s) => s.mailboxesLoaded); const loaded = useMail((s) => s.mailboxesLoaded || s.mailboxesCached);
const [location] = useLocation(); const [location] = useLocation();
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined; const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
const showHidden = useSettings((s) => s.settings.showHiddenFolders); const showHidden = useSettings((s) => s.settings.showHiddenFolders);
+25 -1
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
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 { 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";
@@ -718,6 +718,27 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
}, },
}); });
/*
* A conversation starts loading when the pointer settles on its row, or the
* moment a finger or button goes down, so that on a slow link the click
* finds it on its way. Drafts open in the composer instead.
*/
const hover = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(hover.current), []);
const prefetch = () => {
if (!isDrafts) useMail.getState().prefetchThread(e.threadId);
};
const onPointerEnter = (ev: PointerEvent) => {
if (ev.pointerType !== "mouse") return;
window.clearTimeout(hover.current);
hover.current = window.setTimeout(prefetch, 80);
};
const onPointerLeave = () => window.clearTimeout(hover.current);
const onPointerDown = (ev: PointerEvent<HTMLDivElement>) => {
prefetch();
gesture.onPointerDown?.(ev);
};
const onDragStart = (ev: DragEvent) => { const onDragStart = (ev: DragEvent) => {
// Read when the drag starts, so the row need not re-render on every change of selection. // Read when the drag starts, so the row need not re-render on every change of selection.
const selectedIds = useMail.getState().selected; const selectedIds = useMail.getState().selected;
@@ -754,6 +775,9 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
draggable={!touch} draggable={!touch}
onDragStart={onDragStart} onDragStart={onDragStart}
{...gesture} {...gesture}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
role="row" role="row"
aria-selected={selected} aria-selected={selected}
> >
+37 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vitest/config"; import { defineConfig, type Plugin } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs"; import { resolveVersion } from "../scripts/version.mjs";
@@ -22,9 +22,44 @@ const version = resolveVersion();
*/ */
const base = baseUrlOf(process.env.BASE_PATH); const base = baseUrlOf(process.env.BASE_PATH);
/*
* Every file the build made, written into the app page for the service worker.
*
* The page names only what it loads at start; the composer, settings, viewers
* and the rest arrive when first used, and after each deploy that first use
* went back to the server. With the whole list in the page, the worker can
* fetch them in the background once a new version is seen, and knows to keep
* them. Language catalogs are listed apart: a reader wants one of them, which
* is cached when it is first loaded.
*
* An inert JSON block rather than prefetch links, which the browser would
* fetch on every load.
*/
function assetList(): Plugin {
return {
name: "ihasmail-asset-list",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const precache: string[] = [];
const onDemand: string[] = [];
for (const file of Object.values(ctx.bundle)) {
if (!file.fileName.startsWith("assets/") || file.fileName.endsWith(".map")) continue;
const catalog = file.type === "chunk" && file.moduleIds.length > 0 && file.moduleIds.every((id) => /[\\/]src[\\/]locales[\\/][^\\/]+\.ts$/.test(id));
(catalog ? onDemand : precache).push(`${base}${file.fileName}`);
}
const json = JSON.stringify({ precache: precache.sort(), onDemand: onDemand.sort() });
return html.replace("</body>", ` <script type="application/json" id="ihasmail-assets">${json}</script>\n </body>`);
},
},
};
}
export default defineConfig({ export default defineConfig({
base, base,
plugins: [react()], plugins: [react(), assetList()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: { resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },