Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2740129c6a | ||
|
|
82dc877fe1 | ||
|
|
4c67460450 | ||
|
|
e158ebac5a | ||
|
|
786976312f | ||
|
|
5fe89d6e15 | ||
|
|
f79915aa89 | ||
|
|
191c4e7e68 | ||
|
|
4c1ceca8e9 | ||
|
|
8a08c3d6db |
+18
-5
@@ -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
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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)));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…": "送信中…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…": "Отправка…",
|
||||||
|
|||||||
@@ -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…": "Надсилання…",
|
||||||
|
|||||||
@@ -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…": "正在发送…",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]> => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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)) },
|
||||||
|
|||||||
Reference in New Issue
Block a user