mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-02 11:07:06 +00:00
build the app, and ship it on Linux and Windows as well as macOS
The client itself: the sync engine and mirror, the Gmail and IMAP providers, the screener, the three boxes and two piles, compose and send, the sanitiser and tracker stripping, contacts, clips, backup, notifications and the guide. The pipeline that ships it. Three runners now build in parallel and the publish gate wants all four platform keys in latest.json before a release leaves draft. Linux gets two more packages Tauri does not build: a flatpak repackaged from the deb against GNOME 48, and a Nix package that relinks the deb against nixpkgs so it runs as a native Wayland client rather than through Xwayland. CI runs the Rust suite on all three desktops rather than on Linux alone, and rebuilds the flatpak manifest on every push to main. Two things that were only ever exercised on macOS and were wrong everywhere else. The menu bar was built by adjusting the submenus macOS is given, so on a platform whose default menu has no File or View the new File menu landed after Edit, Check for Updates landed nowhere, and the three places and the reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri already emits. The updater key exists now, so releases can sign their artifacts.
This commit is contained in:
1 parent
088ec9c6e4
commit
eb59cb5f8d
423 files changed
+93217
-251
No files matched your search
+447
-1
@@ -1,5 +1,451 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
|
||||
import { Connect } from "./screens/Connect";
|
||||
import { Header } from "./screens/Header";
|
||||
import { ListColumn } from "./screens/ListColumn";
|
||||
import { Clips } from "./screens/Clips";
|
||||
import { Compose } from "./screens/Compose";
|
||||
import { ContactCards } from "./screens/ContactCards";
|
||||
import { Contacts } from "./screens/Contacts";
|
||||
import { Feed } from "./screens/Feed";
|
||||
import { Files } from "./screens/Files";
|
||||
import { FocusReply } from "./screens/FocusReply";
|
||||
import { Guide } from "./screens/Guide";
|
||||
import { HelpButton } from "./screens/Help";
|
||||
import { Onboarding } from "./screens/Onboarding";
|
||||
import { Tour } from "./screens/Tour";
|
||||
import { Arriving } from "./screens/Arriving";
|
||||
import { Screener } from "./screens/Screener";
|
||||
import { ReadingPane } from "./screens/ReadingPane";
|
||||
import { Settings } from "./screens/Settings";
|
||||
import { CommandPalette } from "./screens/CommandPalette";
|
||||
import { Toasts } from "./screens/Toasts";
|
||||
import { ShortcutsSheet } from "./keys/Shortcuts";
|
||||
|
||||
import { notifyTake } from "./api/notifications";
|
||||
import { useEscapeLayer } from "./escape";
|
||||
import { registerCommands } from "./keys/commands";
|
||||
import { setAccountSwitch, useKeyContext, useKeymap } from "./keys/keymap";
|
||||
import { handleMenuAction } from "./keys/menu";
|
||||
import { useAccounts } from "./store/useAccounts";
|
||||
import { useMail } from "./store/useMail";
|
||||
import { useStage } from "./store/useStage";
|
||||
import { useOverlays } from "./store/useOverlays";
|
||||
import { useScreener } from "./store/useScreener";
|
||||
import { useSettings } from "./store/useSettings";
|
||||
import { useSnooze } from "./store/useSnooze";
|
||||
import { announce, useSync } from "./store/useSync";
|
||||
import { useTheme } from "./store/useTheme";
|
||||
import { notify } from "./store/useToast";
|
||||
import { usePhone, useTouch } from "./useMedia";
|
||||
import { isDesktop, isTauri, live, type AuthEvent, type Place, type SyncStatus } from "./ipc";
|
||||
|
||||
// The Kit page renders every primitive in every state, and it is how a restyle is reviewed: one
|
||||
// page, light and dark, screenshotted by Playwright. `import.meta.env.DEV` is a compile-time
|
||||
// constant, so the whole branch and the module behind it are absent from a production bundle.
|
||||
const Kit = import.meta.env.DEV ? lazy(() => import("./screens/Kit")) : null;
|
||||
|
||||
function useHashRoute(): string {
|
||||
const [hash, setHash] = useState(() => window.location.hash);
|
||||
useEffect(() => {
|
||||
const onChange = () => setHash(window.location.hash);
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}, []);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's three events, from whichever side is serving them.
|
||||
*
|
||||
* In Tauri they arrive through the event bus. Opened in a browser `src/dev/mockIpc.ts` dispatches
|
||||
* the same three payloads as window CustomEvents under the same names, which is what lets the whole
|
||||
* connect flow be driven and watched without a Google account, by a person or by Playwright.
|
||||
*/
|
||||
function onAppEvent<T>(name: string, handler: (payload: T) => void): () => void {
|
||||
if (isTauri) {
|
||||
const stop = listen<T>(name, (event) => handler(event.payload));
|
||||
return () => void stop.then((off) => off());
|
||||
}
|
||||
const onCustom = (event: Event) => handler((event as CustomEvent<T>).detail);
|
||||
window.addEventListener(name, onCustom);
|
||||
return () => window.removeEventListener(name, onCustom);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a burst of `store-changed` gathers before the list asks for a page again.
|
||||
*
|
||||
* The sync engine runs a pass every twelve seconds and reports on every pass that touched
|
||||
* anything, and a mailbox that is still coming in touches something every time; one pass can also
|
||||
* name several scopes in a row. 250ms is short enough that a list which really did change still
|
||||
* looks immediate (nothing here is a keystroke, and the eye reads under a third of a second as
|
||||
* "already there"), and long enough that a pass costs one query rather than one per event.
|
||||
*/
|
||||
const RELOAD_MS = 250;
|
||||
|
||||
/** Where the Help menu's Report an Issue goes. docs/release.md names the repository. */
|
||||
const ISSUES_URL = "https://github.com/priyanshujain/margin-mail/issues/new";
|
||||
|
||||
/**
|
||||
* The Help menu's Check for Updates. It is the About section's button without the section: the
|
||||
* same call, answered in toasts because there is no card here to write the answer on, and every
|
||||
* one of them is an answer to something the person just asked for.
|
||||
*/
|
||||
async function checkForUpdates(): Promise<void> {
|
||||
if (!isDesktop) return;
|
||||
try {
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
notify("This is the newest there is.");
|
||||
return;
|
||||
}
|
||||
notify(`Margin Mail ${update.version} is ready to install`, {
|
||||
label: "Install",
|
||||
run: () => {
|
||||
void update
|
||||
.downloadAndInstall()
|
||||
.then(() => relaunch())
|
||||
.catch((e) => notify(`Could not install that update: ${e}`));
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
notify(`Could not check for updates: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on a notification: the account it was about, the place its thread shows in, and the
|
||||
* thread when it named one. The backend holds where the click pointed until asked, because the
|
||||
* click that launches the app lands before this code is listening; so the launch and the event
|
||||
* both ask the same way, and whichever asks second finds nothing.
|
||||
*/
|
||||
async function openFromNotification(): Promise<void> {
|
||||
const target = await notifyTake().catch(() => null);
|
||||
if (!target) return;
|
||||
useSettings.getState().close();
|
||||
useStage.getState().close();
|
||||
const mail = useMail.getState();
|
||||
if (useAccounts.getState().accounts.some((a) => a.id === target.accountId)) {
|
||||
mail.setAccount(target.accountId);
|
||||
}
|
||||
mail.goTo(target.place);
|
||||
if (target.threadKey) await useMail.getState().open(target.threadKey);
|
||||
}
|
||||
|
||||
const PLACE_COMMANDS: [string, Place][] = [
|
||||
["place-inbox", "inbox"],
|
||||
["place-feed", "feed"],
|
||||
["place-paper-trail", "paper-trail"],
|
||||
["place-reply-later", "reply-later"],
|
||||
["place-set-aside", "set-aside"],
|
||||
["place-screener", "screener"],
|
||||
["place-snoozed", "snoozed"],
|
||||
["place-everything", "everything"],
|
||||
];
|
||||
|
||||
function Shell() {
|
||||
usePhone();
|
||||
useTouch();
|
||||
useKeymap();
|
||||
|
||||
const overlay = useOverlays((s) => s.open);
|
||||
const closeOverlay = useOverlays((s) => s.close);
|
||||
const pane = useMail((s) => s.pane);
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const loaded = useAccounts((s) => s.loaded);
|
||||
const connect = useAccounts((s) => s.phase);
|
||||
const origin = useAccounts((s) => s.origin);
|
||||
const place = useMail((s) => s.place);
|
||||
const stage = useStage((s) => s.open);
|
||||
|
||||
// Nothing runs in the background and nothing fires at an exact time, so a snooze comes back when
|
||||
// somebody looks: on open, and on every return to the foreground. It belongs to the window rather
|
||||
// than to the list, or a snooze would not come back while the Feed or a library was up.
|
||||
useEffect(() => {
|
||||
const run = () => void useSnooze.getState().evaluate();
|
||||
run();
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") run();
|
||||
};
|
||||
window.addEventListener("focus", run);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.removeEventListener("focus", run);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The four screens that are a stage rather than a place. They are registered here rather than in
|
||||
// each screen because a screen that is not on cannot register the command that turns it on.
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands({
|
||||
"open-contacts": () => useStage.getState().toggle("contacts"),
|
||||
"open-clips": () => useStage.getState().toggle("clips"),
|
||||
"open-files": () => useStage.getState().toggle("files"),
|
||||
"focus-reply": () => useStage.getState().toggle("focus-reply"),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const settings = useSettings((s) => s.open);
|
||||
|
||||
// The connect screen is the whole app until there is an account, and it keeps the stage through
|
||||
// the consent and the first sync it narrates. Adding an account or granting a scope from Settings
|
||||
// runs the same flow and must not take the window over, which is what the origin distinguishes.
|
||||
const welcome = loaded && (accounts.length === 0 || (origin === "welcome" && connect !== "idle"));
|
||||
|
||||
// One column with a thread in it: the list gave up the width, so the thread is the page and
|
||||
// Escape is the way back to the list.
|
||||
const onePage = !pane && openKey !== null;
|
||||
useEscapeLayer(onePage, () => useMail.getState().close());
|
||||
// A stage is somewhere you went rather than something that opened over you, so Escape puts you
|
||||
// back on the place you left rather than closing anything underneath it.
|
||||
useEscapeLayer(stage !== null, () => useStage.getState().close());
|
||||
|
||||
// An open panel shadows the whole view keymap while leaving the global one reachable, which is
|
||||
// what keeps `j` from walking the list behind the shortcuts sheet. Decided here rather than in
|
||||
// each panel, because whether something is in front is the window's question.
|
||||
useKeyContext("overlay", overlay !== null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!live()) return;
|
||||
void (async () => {
|
||||
await useAccounts.getState().refresh();
|
||||
const accounts = useAccounts.getState().accounts;
|
||||
// Every mailbox at once is a choice rather than a default, so the app opens on one account.
|
||||
if (accounts.length > 0) useMail.getState().setAccount(accounts[0].id);
|
||||
else await useMail.getState().load();
|
||||
void useSync.getState().refresh();
|
||||
// A notification clicked while the app was not running is what launched it.
|
||||
await openFromNotification();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
return onAppEvent<null>("notification-open", () => void openFromNotification());
|
||||
}, []);
|
||||
|
||||
|
||||
// The verbs that belong to the window rather than to a column. The triage and writing verbs are
|
||||
// deliberately absent: this milestone has nothing to act with, and an unregistered command does
|
||||
// nothing at all, which is what docs/keyboard.md asks for.
|
||||
useEffect(() => {
|
||||
// Going somewhere leaves settings, because settings is a place and you cannot be in two.
|
||||
const places = Object.fromEntries(
|
||||
PLACE_COMMANDS.map(([command, place]) => [
|
||||
command,
|
||||
() => {
|
||||
// Settings and a library are both in front of the place, and going somewhere means
|
||||
// leaving them: a stage wins over a place, so a place changed underneath one is a place
|
||||
// nobody can see.
|
||||
useSettings.getState().close();
|
||||
useStage.getState().close();
|
||||
useMail.getState().goTo(place);
|
||||
},
|
||||
]),
|
||||
);
|
||||
return registerCommands({
|
||||
...places,
|
||||
"toggle-pane": () => useMail.getState().togglePane(),
|
||||
"command-palette": () => useOverlays.getState().toggle("palette"),
|
||||
// Settings is a place inside the app, and the welcome screen is not inside the app yet.
|
||||
settings: () => {
|
||||
if (useAccounts.getState().accounts.length > 0) useSettings.getState().show();
|
||||
},
|
||||
shortcuts: () => useOverlays.getState().toggle("shortcuts"),
|
||||
// Always `show` rather than `toggle`: the tour is opened from the help menu, and a toggle
|
||||
// would close it again for anyone who reached the menu from the tour's own last slide.
|
||||
tour: () => useOverlays.getState().show("tour"),
|
||||
guide: () => useOverlays.getState().show("guide"),
|
||||
"sync-now": () => void useSync.getState().run(),
|
||||
"toggle-theme": () => useTheme.getState().toggle(),
|
||||
accounts: () => useMail.getState().setAccount(null),
|
||||
// The two Help menu items. Neither has a key, so nothing but the menu ever emits them.
|
||||
"check-updates": () => void checkForUpdates(),
|
||||
"report-issue": () => {
|
||||
if (!isTauri) window.open(ISSUES_URL, "_blank", "noopener,noreferrer");
|
||||
else openUrl(ISSUES_URL).catch((e) => notify(`Could not open the browser: ${e}`));
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Nine keys and one idea, so it is not a command with nine bindings.
|
||||
useEffect(
|
||||
() =>
|
||||
setAccountSwitch((index) => {
|
||||
const account = useAccounts.getState().accounts[index];
|
||||
if (account) useMail.getState().setAccount(account.id);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
return onAppEvent<string>("menu-action", handleMenuAction);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A burst of scopes costs one query. The timer is not reset by the events that arrive while it
|
||||
// is pending, so a run of them cannot hold the list off indefinitely: the first one decides
|
||||
// when the query goes out and the rest join it.
|
||||
let pending: number | undefined;
|
||||
const reloadList = () => {
|
||||
if (pending !== undefined) return;
|
||||
pending = window.setTimeout(() => {
|
||||
pending = undefined;
|
||||
void useMail.getState().load();
|
||||
}, RELOAD_MS);
|
||||
};
|
||||
|
||||
// The reason is a scope and not a bell: `threads`, `thread` or `thread:<key>`, `accounts`,
|
||||
// `screener`, and the rest. Reloading the whole list on every one of them was the jank, because
|
||||
// a pass warming the body cache says so several times a second and each of those rebuilt every
|
||||
// row and made react-virtuoso measure the list again.
|
||||
const changed = onAppEvent<string>("store-changed", (reason) => {
|
||||
const scopes = (reason ?? "").split(" ").filter(Boolean);
|
||||
// An emitter that named no scope has changed something it could not name, and the list is the
|
||||
// only safe reading of that.
|
||||
if (scopes.length === 0 || scopes.includes("threads")) reloadList();
|
||||
// A body landing changes what the open thread shows and nothing at all about the row above
|
||||
// it, so it re-reads that thread in place rather than the page it sits in. That is the bare
|
||||
// scope, and it is the one the cache warmer emits several times a second.
|
||||
if (scopes.some((scope) => scope === "thread" || scope.startsWith("thread:"))) {
|
||||
void useMail.getState().refreshThread();
|
||||
}
|
||||
// A scope that names a thread is a decision somebody took about that thread: a note, a
|
||||
// rename, a merge. Those show on the row as well as in the pane, so this one does reach the
|
||||
// list, and joins whatever query is already pending.
|
||||
if (scopes.some((scope) => scope.startsWith("thread:"))) reloadList();
|
||||
if (scopes.includes("accounts")) void useAccounts.getState().refresh();
|
||||
// The Screener's pill counts senders waiting rather than rows, so it is not part of the page
|
||||
// the list loaded and it has to be asked for by name.
|
||||
if (scopes.includes("screener")) {
|
||||
void useScreener.getState().load(useMail.getState().accountId);
|
||||
}
|
||||
});
|
||||
|
||||
// The consent flow's other half. `account_connect` hands back a URL and returns; what actually
|
||||
// happened arrives here, minutes later if the person went to make tea.
|
||||
const auth = onAppEvent<AuthEvent>("auth", (event) => {
|
||||
void useAccounts.getState().handleAuthEvent(event);
|
||||
});
|
||||
|
||||
// A pass that fails in the background used to set the error and say nothing, so mail that never
|
||||
// arrived looked like mail you do not have. Then it said so on every pass, because the engine
|
||||
// reports at the start and the end of each one, the same trouble came back every twelve
|
||||
// seconds, and a transport failure carried a different URL each time so nothing matched.
|
||||
// `announce` decides: once per distinct trouble per account, nothing for offline or a rate
|
||||
// limit (the account chip has those), and it forgets what it said once a pass ends clean so
|
||||
// the trouble coming back is news.
|
||||
//
|
||||
// This is also the only thing that keeps the sync store current. Every pass reports its status
|
||||
// through the same sink that emits `store-changed`, and the payload here is the whole
|
||||
// `SyncStatus`, so asking `sync_status` again on every invalidation was a round trip for
|
||||
// something the app had already been handed.
|
||||
const said = new Map<string, string | null>();
|
||||
const progress = onAppEvent<SyncStatus>("sync-progress", (status) => {
|
||||
useSync.getState().apply(status);
|
||||
// A seed the mirror was not ready for is asked for again when the account's pass ends.
|
||||
// The engine has seeded by then if the crawl finished, and the answer is the count the
|
||||
// first-run panel wants; if it did not, the answer is "not yet" again and this waits on.
|
||||
if (status.phase === "idle" && useAccounts.getState().seeding === status.accountId) {
|
||||
void useAccounts.getState().seedScreener(status.accountId);
|
||||
}
|
||||
const { toast, last } = announce(status, said.get(status.accountId) ?? null);
|
||||
if (toast) notify(toast);
|
||||
said.set(status.accountId, last);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(pending);
|
||||
changed();
|
||||
auth();
|
||||
progress();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Paper and nothing on it until the account list has come back, which is a few milliseconds
|
||||
// either way. Without it a first launch paints the header and an empty list for one frame before
|
||||
// the welcome screen replaces both, and that frame reads as a mailbox that lost your mail.
|
||||
if (!loaded && live() && connect !== "error") return <div className="app" />;
|
||||
|
||||
// Nothing of the app is drawn behind the welcome screen. There is no account, so the header would
|
||||
// be a chip with nobody in it over a list of nothing.
|
||||
if (welcome) {
|
||||
return (
|
||||
<div className="app">
|
||||
<Connect />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// The place is on the root as well as in the store, because it decides which screen is up and
|
||||
// three of them are not the list column: a test, and a stylesheet, needs one place to ask.
|
||||
<div className="app" data-place={place} data-stage={stage ?? undefined}>
|
||||
<Header />
|
||||
{/* The Feed and the Screener take the whole stage rather than a list column beside a pane,
|
||||
because their content is inline: a Feed card is already open and a Screener card is the
|
||||
message itself. Every other place is the list and the pane. */}
|
||||
{settings ? (
|
||||
<Settings />
|
||||
) : stage === "contacts" ? (
|
||||
<Contacts />
|
||||
) : stage === "clips" ? (
|
||||
<Clips />
|
||||
) : stage === "files" ? (
|
||||
<Files />
|
||||
) : stage === "focus-reply" ? (
|
||||
<FocusReply />
|
||||
) : place === "feed" ? (
|
||||
<Feed />
|
||||
) : place === "screener" ? (
|
||||
<Screener />
|
||||
) : (
|
||||
<main className="stage">
|
||||
{onePage ? null : <ListColumn />}
|
||||
{pane || onePage ? <ReadingPane /> : null}
|
||||
</main>
|
||||
)}
|
||||
{/* The compose card floats over the whole stage and belongs to none of the screens under it,
|
||||
so it is mounted once here. Anywhere else and `c` would only work where that screen was:
|
||||
the Feed, the Screener and the libraries would each have a Write button that did nothing. */}
|
||||
<Compose />
|
||||
<ContactCards />
|
||||
<CommandPalette />
|
||||
<ShortcutsSheet open={overlay === "shortcuts"} onClose={closeOverlay} />
|
||||
<Tour />
|
||||
<Guide />
|
||||
{/* The corner button is over the window rather than in any screen, for the same reason the
|
||||
compose card is: help that was only reachable from the Inbox would be missing from the
|
||||
places somebody actually gets stuck in. */}
|
||||
<HelpButton />
|
||||
<Onboarding />
|
||||
<Arriving />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <div className="app" />;
|
||||
const route = useHashRoute();
|
||||
|
||||
if (Kit && route === "#/kit") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Kit />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <Shell />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { call, type Account } from "../ipc";
|
||||
|
||||
export const accountsList = () => call<Account[]>("accounts_list");
|
||||
|
||||
/**
|
||||
* Returns the consent URL. Completion arrives as the `auth` event.
|
||||
*
|
||||
* `loginHint` is the address typed on the connect screen, so the consent page opens on that account
|
||||
* rather than on a chooser.
|
||||
*
|
||||
* `extraScopes` is how a feature asks for a scope it needs and does not have. Google does not
|
||||
* support incremental authorization for installed apps, so this re-runs the whole consent with the
|
||||
* base list plus the extras and replaces the stored token; it is never a second, narrower grant.
|
||||
*/
|
||||
export const accountConnect = (extraScopes?: string[], loginHint?: string) =>
|
||||
call<string>("account_connect", { extraScopes: extraScopes ?? [], loginHint: loginHint ?? null });
|
||||
|
||||
/**
|
||||
* Sets the window an account just added is to hold, hands it to the sync engine and starts its
|
||||
* first pass. Until this is called the account is in the registry and nowhere else.
|
||||
*/
|
||||
export const accountStart = (accountId: string, windowDays: number) =>
|
||||
call<void>("account_start", { accountId, windowDays });
|
||||
|
||||
/** Re-runs consent for an account that is already linked, to pick up a scope that was withheld. */
|
||||
export const accountGrant = (accountId: string, extraScopes?: string[]) =>
|
||||
call<string>("account_grant", { accountId, extraScopes: extraScopes ?? [] });
|
||||
|
||||
/**
|
||||
* The one way an account leaves this device. Margin's access is revoked at Google first, for every
|
||||
* Margin app on every machine because the three share one OAuth client, then the token and the
|
||||
* account are forgotten here. `keepData` leaves the mirror and the decisions on disk, set aside for
|
||||
* the day the account is added again.
|
||||
*/
|
||||
export const accountRemove = (accountId: string, keepData: boolean) =>
|
||||
call<void>("account_remove", { accountId, keepData });
|
||||
|
||||
export const accountSetColor = (accountId: string, color: string) =>
|
||||
call<void>("account_set_color", { accountId, color });
|
||||
|
||||
export const accountSetName = (accountId: string, name: string) =>
|
||||
call<void>("account_set_name", { accountId, name });
|
||||
@@ -0,0 +1,15 @@
|
||||
import { call, type BackupSettings } from "../ipc";
|
||||
|
||||
export const backupStatus = () => call<BackupSettings>("backup_status");
|
||||
|
||||
/** `store` is "drive" or "r2". R2 takes the four S3 fields; Drive takes none. */
|
||||
export const backupConfigure = (store: string, config: Record<string, string>) =>
|
||||
call<BackupSettings>("backup_configure", { store, config });
|
||||
|
||||
export const backupNow = () => call<BackupSettings>("backup_now");
|
||||
|
||||
/** Shown once, at setup, and never returned again. */
|
||||
export const backupPhrase = () => call<string>("backup_phrase");
|
||||
|
||||
/** Attaches this device to an existing backup, which is also how a lost device is replaced. */
|
||||
export const backupRestore = (phrase: string) => call<void>("backup_restore", { phrase });
|
||||
@@ -0,0 +1,17 @@
|
||||
import { call, type ContactCard, type ContactPatch, type Person } from "../ipc";
|
||||
|
||||
export const contactCard = (accountId: string, address: string) =>
|
||||
call<ContactCard>("contact_card", { accountId, address });
|
||||
|
||||
export const contactUpdate = (accountId: string, address: string, patch: ContactPatch) =>
|
||||
call<void>("contact_update", { accountId, address, patch });
|
||||
|
||||
export const contactsList = (accountId: string | null, query: string) =>
|
||||
call<ContactCard[]>("contacts_list", { accountId, query });
|
||||
|
||||
/**
|
||||
* Autocomplete. The mirror first, ranked by recency and frequency, then the provider's contacts.
|
||||
* Correspondents' addresses never leave the device to be looked up.
|
||||
*/
|
||||
export const contactsSuggest = (accountId: string, prefix: string) =>
|
||||
call<Person[]>("contacts_suggest", { accountId, prefix });
|
||||
@@ -0,0 +1,39 @@
|
||||
import { call, type Account, type ConnectReport, type MailConfig } from "../ipc";
|
||||
|
||||
/**
|
||||
* Turns an address into a pair of servers, or nothing at all, which is not a failure: it means the
|
||||
* settings have to come from the person and the manual sheet is the next screen.
|
||||
*/
|
||||
export const imapDiscover = (email: string) => call<MailConfig | null>("imap_discover", { email });
|
||||
|
||||
/**
|
||||
* Opens both connections and logs in to each without keeping either.
|
||||
*
|
||||
* Deliberately not a thrown error on refusal. A wrong password, a certificate to decide about and
|
||||
* a host that does not answer are three panels the screen draws, so they come back as a report.
|
||||
* A blank `smtpPassword` means the IMAP one is used, which is what a gateway that shares
|
||||
* credentials with the mail store wants.
|
||||
*/
|
||||
export const imapTest = (config: MailConfig, imapPassword: string, smtpPassword: string | null) =>
|
||||
call<ConnectReport>("imap_test", { config, imapPassword, smtpPassword });
|
||||
|
||||
/** Adds the account. The configuration has already been tested by the screen that calls this. */
|
||||
export const imapConnect = (
|
||||
email: string,
|
||||
name: string,
|
||||
config: MailConfig,
|
||||
imapPassword: string,
|
||||
smtpPassword: string | null,
|
||||
) => call<Account>("imap_connect", { email, name, config, imapPassword, smtpPassword });
|
||||
|
||||
/** The servers an account was set up with, for the Settings section that shows them. */
|
||||
export const imapServers = (accountId: string) =>
|
||||
call<MailConfig | null>("imap_servers", { accountId });
|
||||
|
||||
/** Accepts one certificate on one host and port, and remembers it across restarts. */
|
||||
export const imapTrustCert = (host: string, port: number, fingerprint: string) =>
|
||||
call<void>("imap_trust_cert", { host, port, fingerprint });
|
||||
|
||||
/** Takes that acceptance back, which is the only way out of having trusted the wrong thing. */
|
||||
export const imapForgetCert = (host: string, port: number) =>
|
||||
call<void>("imap_forget_cert", { host, port });
|
||||
@@ -0,0 +1,11 @@
|
||||
import { call, type LabelInfo, type Undo } from "../ipc";
|
||||
|
||||
export const labelsList = (accountId: string | null) =>
|
||||
call<LabelInfo[]>("labels_list", { accountId });
|
||||
|
||||
export const labelApply = (keys: string[], labelId: string, on: boolean) =>
|
||||
call<Undo>("label_apply", { keys, labelId, on });
|
||||
|
||||
/** Applies a label and archives, which is what "move" means to a provider with labels. */
|
||||
export const labelMove = (keys: string[], labelId: string) =>
|
||||
call<Undo>("label_move", { keys, labelId });
|
||||
@@ -0,0 +1,9 @@
|
||||
import { call } from "../ipc";
|
||||
|
||||
/**
|
||||
* One line into the app's log file, beside what the engine writes there. For the failures only
|
||||
* the webview sees: an uncaught error, a promise nobody caught, an open that took too long. A
|
||||
* command that answers with an error is already written down by `call` itself.
|
||||
*/
|
||||
export const logNote = (who: string, line: string) =>
|
||||
call<void>("log_note", { who, line }).catch(() => {});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { call, type MessageView } from "../ipc";
|
||||
|
||||
/**
|
||||
* Re-renders one message with its remote images fetched. Rust does the fetching, without cookies or
|
||||
* a referrer, and inlines the results as `data:` URIs, so the webview never makes a request of its
|
||||
* own and the sender learns nothing but that somebody asked once.
|
||||
*/
|
||||
export const messageShowImages = (messageId: string) =>
|
||||
call<MessageView>("message_show_images", { messageId });
|
||||
|
||||
/** The bytes of an attachment as a `data:` URI, for the inline preview. Fetched on demand. */
|
||||
export const attachmentDataUrl = (attachmentId: string) =>
|
||||
call<string>("attachment_data_url", { attachmentId });
|
||||
|
||||
/** Writes it to the downloads directory and returns the path. */
|
||||
export const attachmentSave = (attachmentId: string) =>
|
||||
call<string>("attachment_save", { attachmentId });
|
||||
|
||||
/** Hands it to the OS to open with whatever owns the type. */
|
||||
export const attachmentOpen = (attachmentId: string) =>
|
||||
call<void>("attachment_open", { attachmentId });
|
||||
@@ -0,0 +1,19 @@
|
||||
import { call, type Clip, type FileCard, type Note, type Undo } from "../ipc";
|
||||
|
||||
export const noteAdd = (threadKey: string, body: string) =>
|
||||
call<Note>("note_add", { threadKey, body });
|
||||
|
||||
export const noteUpdate = (id: string, body: string) => call<void>("note_update", { id, body });
|
||||
|
||||
export const noteDelete = (id: string) => call<Undo>("note_delete", { id });
|
||||
|
||||
export const clipSave = (accountId: string, threadKey: string, messageId: string, text: string) =>
|
||||
call<Clip>("clip_save", { accountId, threadKey, messageId, text });
|
||||
|
||||
export const clipsList = (accountId: string | null) => call<Clip[]>("clips_list", { accountId });
|
||||
|
||||
export const clipDelete = (id: string) => call<Undo>("clip_delete", { id });
|
||||
|
||||
/** `category` filters the All files place: images, pdfs, documents and the rest, or "" for all. */
|
||||
export const filesList = (accountId: string | null, category: string, sender: string) =>
|
||||
call<FileCard[]>("files_list", { accountId, category, sender });
|
||||
@@ -0,0 +1,23 @@
|
||||
import { call, type NotifyPermission, type NotifyTarget } from "../ipc";
|
||||
|
||||
/** Posts one sample notification. */
|
||||
export const notifyTest = () => call<void>("notify_test");
|
||||
|
||||
/**
|
||||
* Whether the system will show this app's notifications: what its settings say, and "prompt"
|
||||
* until the app has asked once. Where there is no permission to ask for the answer is "granted".
|
||||
*/
|
||||
export const notifyPermission = () => call<NotifyPermission>("notify_permission");
|
||||
|
||||
/** Asks, when the system has not been asked yet, and says what the answer is. */
|
||||
export const askForNotifications = () => call<NotifyPermission>("notify_request");
|
||||
|
||||
/** Opens the system's own notification settings for this app, where a refusal is undone. */
|
||||
export const openNotificationSettings = () => call<void>("notify_open_settings");
|
||||
|
||||
/**
|
||||
* Where the last click on a notification pointed, once: null after the first read, and always for
|
||||
* the sample from the settings screen.
|
||||
*/
|
||||
export const notifyTake = () => call<NotifyTarget | null>("notify_take");
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { call, type Destination, type ScreenerCard, type Undo } from "../ipc";
|
||||
|
||||
export const screenerList = (accountId: string | null) =>
|
||||
call<ScreenerCard[]>("screener_list", { accountId });
|
||||
|
||||
/** Sets the rule for one sender. `wholeDomain` keys it on the domain instead of the address. */
|
||||
export const screenerDecide = (
|
||||
accountId: string,
|
||||
address: string,
|
||||
destination: Destination,
|
||||
wholeDomain: boolean,
|
||||
) => call<Undo>("screener_decide", { accountId, address, destination, wholeDomain });
|
||||
|
||||
export const screenerClearAll = (accountId: string | null) =>
|
||||
call<Undo>("screener_clear_all", { accountId });
|
||||
|
||||
/**
|
||||
* The first run pass: screen in everyone already known from the window, the People API and Sent,
|
||||
* routing each by the same suggestion function. Returns how many were screened in.
|
||||
*/
|
||||
/**
|
||||
* Screens in everyone the account already knows, once its first sync has brought the mailbox in.
|
||||
* Null until then: the seed reads what the crawl writes, and it is asked again when the account's
|
||||
* sync reports idle.
|
||||
*/
|
||||
export const screenerSeed = (accountId: string) =>
|
||||
call<number | null>("screener_seed", { accountId });
|
||||
@@ -0,0 +1,12 @@
|
||||
import { call, type SearchResult } from "../ipc";
|
||||
|
||||
/**
|
||||
* Local FTS over the mirror, with the operators from features.md. When the query reaches past the
|
||||
* storage window the provider is asked as a second pass and its hits are hydrated as transient rows.
|
||||
*/
|
||||
export const search = (accountId: string | null, query: string, cursor?: string | null) =>
|
||||
call<SearchResult>("search", { accountId, query, cursor: cursor ?? null });
|
||||
|
||||
/** The explicit "Search older mail on Gmail" at the foot of a result list. */
|
||||
export const searchProvider = (accountId: string, query: string) =>
|
||||
call<SearchResult>("search_provider", { accountId, query });
|
||||
@@ -0,0 +1,28 @@
|
||||
import { call, type Settings, type StorageUsed } from "../ipc";
|
||||
|
||||
export const settingsGet = () => call<Settings>("settings_get");
|
||||
|
||||
/** A partial patch. Absent fields are unchanged. Written atomically. */
|
||||
export const settingsSet = (patch: Partial<Settings>) => call<Settings>("settings_set", { patch });
|
||||
|
||||
/** The families installed on this machine, for the Appearance section's two font pickers. */
|
||||
export const systemFonts = () => call<string[]>("system_fonts");
|
||||
|
||||
/** The path of the keymap file, so the Keyboard section can open it in the editor. */
|
||||
export const keymapPath = () => call<string>("keymap_path");
|
||||
|
||||
export const keymapReset = () => call<void>("keymap_reset");
|
||||
|
||||
export const storageUsed = () => call<StorageUsed[]>("storage_used");
|
||||
|
||||
/** Throws the mirror away and syncs it again. The state database is untouched. */
|
||||
export const mirrorClear = (accountId: string) => call<void>("mirror_clear", { accountId });
|
||||
|
||||
export const exportMbox = (accountId: string) => call<string>("export_mbox", { accountId });
|
||||
|
||||
export const exportState = () => call<string>("export_state");
|
||||
|
||||
export const importState = (path: string) => call<void>("import_state", { path });
|
||||
|
||||
/** The package manager that owns this install, when one does. */
|
||||
export const packagedBy = () => call<string | null>("packaged_by");
|
||||
@@ -0,0 +1,12 @@
|
||||
import { call, type SyncStatus } from "../ipc";
|
||||
|
||||
/** One account, or every account when the id is omitted. */
|
||||
export const syncNow = (accountId?: string) => call<SyncStatus[]>("sync_now", { accountId });
|
||||
|
||||
export const syncStatus = () => call<SyncStatus[]>("sync_status");
|
||||
|
||||
/** Drains the outbox. The close-request hook races this against a timeout. */
|
||||
export const syncFlush = () => call<void>("sync_flush");
|
||||
|
||||
/** Fills in the range a widened storage window newly covers, newest first. */
|
||||
export const syncBackfill = (accountId: string) => call<void>("sync_backfill", { accountId });
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
call,
|
||||
type FlagPatch,
|
||||
type Pile,
|
||||
type SnoozeKind,
|
||||
type ThreadPage,
|
||||
type ThreadQuery,
|
||||
type ThreadView,
|
||||
type Undo,
|
||||
} from "../ipc";
|
||||
|
||||
export const threadsList = (query: ThreadQuery) => call<ThreadPage>("threads_list", { query });
|
||||
|
||||
export const threadView = (key: string) => call<ThreadView>("thread_view", { key });
|
||||
|
||||
/**
|
||||
* Fetches the bodies `thread_view` came back without, and answers with how many arrived. The view
|
||||
* is read again rather than patched from this, because what a message renders as is the mirror's
|
||||
* answer and not this call's.
|
||||
*/
|
||||
export const threadHydrate = (key: string) => call<number>("thread_hydrate", { key });
|
||||
|
||||
/**
|
||||
* Opening is what marks a thread seen, and this is the mirror being told. Nothing comes back: it
|
||||
* is not a verb, it is not on the undo stack, and a thread already seen is a no-op on the far side.
|
||||
*/
|
||||
export const threadOpened = (key: string) => call<void>("thread_opened", { key });
|
||||
|
||||
/** Seen, starred, archived, trashed and spam are the provider's flags, so these go through it. */
|
||||
export const flagsSet = (keys: string[], patch: FlagPatch) =>
|
||||
call<Undo>("flags_set", { keys, patch });
|
||||
|
||||
export const markAllSeen = (accountId: string | null, place: string) =>
|
||||
call<Undo>("mark_all_seen", { accountId, place });
|
||||
|
||||
/**
|
||||
* The only bulk write to the provider the app ever proposes: mark everything older than a date as
|
||||
* seen. Reversible for seven days, which is what the token is for.
|
||||
*/
|
||||
export const startFresh = (accountId: string, olderThanMs: number) =>
|
||||
call<Undo>("start_fresh", { accountId, olderThanMs });
|
||||
|
||||
export const pileToggle = (keys: string[], pile: Pile) => call<Undo>("pile_toggle", { keys, pile });
|
||||
|
||||
export const snoozeSet = (keys: string[], kind: SnoozeKind, returnAtMs: number) =>
|
||||
call<Undo>("snooze_set", { keys, kind, returnAtMs });
|
||||
|
||||
export const snoozeClear = (keys: string[]) => call<Undo>("snooze_clear", { keys });
|
||||
|
||||
/** Runs on open, on foreground and on wake. Returns the threads that came back. */
|
||||
export const snoozeEvaluate = () => call<string[]>("snooze_evaluate");
|
||||
|
||||
export const threadRename = (key: string, name: string | null) =>
|
||||
call<Undo>("thread_rename", { key, name });
|
||||
|
||||
/** The merged thread takes the first key in the list, which is also the one Unmerge undoes from. */
|
||||
export const threadMerge = (keys: string[], name: string | null) =>
|
||||
call<Undo>("thread_merge", { keys, name });
|
||||
|
||||
export const threadUnmerge = (key: string) => call<Undo>("thread_unmerge", { key });
|
||||
|
||||
export const threadIgnore = (keys: string[], on: boolean) =>
|
||||
call<Undo>("thread_ignore", { keys, on });
|
||||
|
||||
export const threadNotify = (keys: string[], on: boolean) =>
|
||||
call<Undo>("thread_notify", { keys, on });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { call } from "../ipc";
|
||||
|
||||
/** `z`. Reverses the most recent reversible action, including a send inside its delay. */
|
||||
export const undoLast = () => call<string | null>("undo_last");
|
||||
|
||||
/** The toast's own Undo button, which names the action it belongs to rather than the latest one. */
|
||||
export const undoToken = (token: string) => call<void>("undo_token", { token });
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
call,
|
||||
type Draft,
|
||||
type DraftSaved,
|
||||
type InviteResponse,
|
||||
type Outgoing,
|
||||
type Undo,
|
||||
} from "../ipc";
|
||||
|
||||
/** Saves locally on every keystroke's debounce and to the provider every few seconds. */
|
||||
export const draftSave = (draft: Draft) => call<DraftSaved>("draft_save", { draft });
|
||||
|
||||
export const draftGet = (id: string) => call<Draft>("draft_get", { id });
|
||||
|
||||
export const draftDelete = (id: string) => call<void>("draft_delete", { id });
|
||||
|
||||
/** Queues the send, held for the undo delay. The `Undo` it returns is what the toast counts down. */
|
||||
export const send = (draft: Draft) => call<Undo>("send", { draft });
|
||||
|
||||
/** Skips the remaining hold. */
|
||||
export const sendNow = (outgoingId: string) => call<void>("send_now", { outgoingId });
|
||||
|
||||
export const outboxList = () => call<Outgoing[]>("outbox_list");
|
||||
|
||||
/** Accept, maybe or decline, through the Calendar API on the invited calendar. */
|
||||
export const inviteRespond = (messageId: string, response: InviteResponse) =>
|
||||
call<void>("invite_respond", { messageId, response });
|
||||
|
||||
/**
|
||||
* One click where the sender supports RFC 8058, the mailto where they do not, the link otherwise.
|
||||
* `alsoTrash` and `alsoScreenOut` are the two follow-ups the confirmation offers.
|
||||
*/
|
||||
export const unsubscribe = (
|
||||
accountId: string,
|
||||
address: string,
|
||||
alsoTrash: boolean,
|
||||
alsoScreenOut: boolean,
|
||||
) => call<Undo>("unsubscribe", { accountId, address, alsoTrash, alsoScreenOut });
|
||||
@@ -0,0 +1,43 @@
|
||||
// The three appearance settings that are a fact about the page rather than a fact in a store: the
|
||||
// two font slots, the reading size, and the theme when it is set to follow the system.
|
||||
//
|
||||
// Out here rather than inside the settings store for the reason src/theme.ts and src/pane.ts are
|
||||
// out here: a store may not touch the DOM, and all three of these have to be on the root before a
|
||||
// paint rather than after one. The blocking script in index.html restores them before the first
|
||||
// paint, so the shapes written to localStorage here are the shapes that script reads back.
|
||||
|
||||
import { fontStack, type FontRef } from "margin-shared/fonts";
|
||||
import type { Theme } from "./theme";
|
||||
|
||||
const FONTS_KEY = "marginmail-fonts";
|
||||
const SIZE_KEY = "marginmail-text-size";
|
||||
const THEME_KEY = "marginmail-theme";
|
||||
|
||||
/** The interface face and the text face, as the two CSS stacks the boot script assigns. */
|
||||
export function applyFonts(ui: FontRef, text: FontRef): void {
|
||||
const stacks = { ui: fontStack(ui), text: fontStack(text) };
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--font-ui", stacks.ui);
|
||||
root.style.setProperty("--font-heading", stacks.text);
|
||||
localStorage.setItem(FONTS_KEY, JSON.stringify(stacks));
|
||||
}
|
||||
|
||||
/** Message bodies and nothing else. The chrome is already at the size it wants to be. */
|
||||
export function applyTextSize(px: number): void {
|
||||
document.documentElement.style.setProperty("--body-size", `${px}px`);
|
||||
localStorage.setItem(SIZE_KEY, String(px));
|
||||
}
|
||||
|
||||
export const systemTheme = (): Theme =>
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
/**
|
||||
* Follow the system, which is the absence of a choice rather than a third value.
|
||||
*
|
||||
* `applyTheme` writes light or dark, and the boot script prefers what it finds there over the
|
||||
* media query, so leaving the key behind would pin the app to whatever the system happened to be
|
||||
* on the evening somebody chose to follow it.
|
||||
*/
|
||||
export function forgetThemeChoice(): void {
|
||||
localStorage.removeItem(THEME_KEY);
|
||||
}
|
||||
+1572
File diff suppressed because it is too large.
Load diff
+1769
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
// Escape unwinds the layers in the order they went up: the popover before the palette, the palette
|
||||
// before the compose card, the card before the pane. A stack rather than a handler per component,
|
||||
// because every component thinking it owns Escape is how two of them close at once.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Handler = () => void;
|
||||
|
||||
const layers: Handler[] = [];
|
||||
let bound = false;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
|
||||
const top = layers[layers.length - 1];
|
||||
if (!top) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
top();
|
||||
}
|
||||
|
||||
function pushLayer(handler: Handler): () => void {
|
||||
if (!bound) {
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
bound = true;
|
||||
}
|
||||
layers.push(handler);
|
||||
return () => {
|
||||
const i = layers.lastIndexOf(handler);
|
||||
if (i !== -1) layers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
|
||||
const latest = useRef(onEscape);
|
||||
latest.current = onEscape;
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return pushLayer(() => latest.current());
|
||||
}, [active]);
|
||||
}
|
||||
+754
-4
@@ -1,10 +1,760 @@
|
||||
// Placeholder. The contract lands in F3.
|
||||
// The IPC contract. Every type here mirrors a struct in src-tauri/src/dto.rs. Both sides are frozen
|
||||
// once written: implementation modules add bodies, not fields.
|
||||
//
|
||||
// Typed per-command wrappers live in src/api/, one module per domain. Nothing outside src/api may
|
||||
// call `call` directly.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** A Tauri build of any shape, phone included. There is a Rust backend behind this. */
|
||||
export const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
export const isMacDesktop =
|
||||
isTauri && typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
||||
|
||||
const isMobileOs =
|
||||
typeof navigator !== "undefined" &&
|
||||
(/android|iphone|ipod/i.test(navigator.userAgent) ||
|
||||
// iPadOS reports itself as a Mac and gives itself away only by having a touchscreen.
|
||||
(/ipad|macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1));
|
||||
|
||||
/**
|
||||
* A Tauri build with a real window behind it: something to drag by its title bar, a close to
|
||||
* intercept before it happens. `core:window:*` sits in the desktop-only capability, so on a phone
|
||||
* those commands are refused rather than ignored.
|
||||
*/
|
||||
export const isDesktop = isTauri && !isMobileOs;
|
||||
|
||||
/** The one window whose title bar has the traffic lights inside the page. */
|
||||
export const isMacDesktop =
|
||||
isDesktop && typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* True when there is a backend to answer a command: Tauri, or the dev fixture in a browser.
|
||||
* Data-loading actions gate on this. Anything touching a window API gates on `isDesktop`, and
|
||||
* anything listening for a Tauri event on `isTauri`, because a phone emits those too.
|
||||
*/
|
||||
export const live = (): boolean => isTauri || import.meta.env.DEV;
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// People, accounts and places
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface Person {
|
||||
name: string | null;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mailbox is behind an account.
|
||||
*
|
||||
* Almost nothing branches on this. It exists because a few screens genuinely differ: an IMAP
|
||||
* account has no scopes to grant, no Google account page to revoke from, and a server and a port
|
||||
* to show in settings that a Google account does not have.
|
||||
*/
|
||||
export type AccountKind = "google" | "imap";
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
email: string;
|
||||
kind: AccountKind;
|
||||
name: string;
|
||||
/** A hue token name (`hue-1`), never a hex. The stylesheet owns the value. */
|
||||
color: string;
|
||||
connected: boolean;
|
||||
/** What Google actually granted. A user can untick a scope, so features check rather than assume. */
|
||||
grantedScopes: string[];
|
||||
/** How far back the mirror keeps this account's mail, in days. Zero means everything. */
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
export type Place =
|
||||
| "inbox"
|
||||
| "feed"
|
||||
| "paper-trail"
|
||||
| "reply-later"
|
||||
| "set-aside"
|
||||
| "screener"
|
||||
| "snoozed"
|
||||
| "everything"
|
||||
| "sent"
|
||||
| "drafts"
|
||||
| "starred"
|
||||
| "screened-out"
|
||||
| "spam"
|
||||
| "trash"
|
||||
| "label"
|
||||
| "search";
|
||||
|
||||
/** Where a sender's mail goes. Exactly one per sender, which is the whole of the routing model. */
|
||||
export type Destination = "inbox" | "feed" | "paper-trail" | "screened-out";
|
||||
|
||||
export type Pile = "reply-later" | "set-aside";
|
||||
|
||||
/** `accountId` of null means every account, which is the All accounts view. */
|
||||
export interface ThreadQuery {
|
||||
accountId?: string | null;
|
||||
place: Place;
|
||||
labelId?: string | null;
|
||||
query?: string | null;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Threads
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One row of a list, already grouped and already ordered.
|
||||
*
|
||||
* `key` is the portable thread key: the first entry of the message's `References` header, else its
|
||||
* `In-Reply-To`, else its own `Message-ID`. It does not depend on which messages happen to be
|
||||
* mirrored, so it survives the storage window changing, a second device, and another provider.
|
||||
*/
|
||||
export interface ThreadSummary {
|
||||
key: string;
|
||||
accountId: string;
|
||||
accountColor: string;
|
||||
/** What to print: the rename when there is one, the real subject otherwise. */
|
||||
subject: string;
|
||||
/** Set only when renamed, so the row can say "renamed · was …". */
|
||||
originalSubject: string | null;
|
||||
from: Person;
|
||||
participants: Person[];
|
||||
snippet: string;
|
||||
dateMs: number;
|
||||
messageCount: number;
|
||||
/** At least one message has not been seen. There is no count anywhere in this app. */
|
||||
unseen: boolean;
|
||||
starred: boolean;
|
||||
/**
|
||||
* In the provider's trash, and in its spam. Flags rather than places: a search result carries
|
||||
* them into a list that is neither, and the row draws the glyph from these.
|
||||
*/
|
||||
trashed: boolean;
|
||||
spam: boolean;
|
||||
hasAttachment: boolean;
|
||||
hasDraft: boolean;
|
||||
pile: Pile | null;
|
||||
snoozedUntil: number | null;
|
||||
ignored: boolean;
|
||||
notify: boolean;
|
||||
merged: boolean;
|
||||
/** The one line under the row. The whole note is in the pane. */
|
||||
note: string | null;
|
||||
/**
|
||||
* Which group this row sits in: a key of `GROUPS` when the group has a head, or `new` and `seen`
|
||||
* in the Inbox, which have none. The view decides both the grouping and the order, so a list
|
||||
* renders a head whenever a headed group changes and never sorts again.
|
||||
*/
|
||||
group: string;
|
||||
sending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every group head, by the key the view sends, in the order a list puts them in. Rows arrive
|
||||
* already sorted into this order, so this is what a head is labelled with rather than what a list
|
||||
* sorts by; sorting twice is how Back ends up in the middle of the Inbox.
|
||||
*
|
||||
* `new` and `seen` are not here on purpose. The Inbox is one list in time order under Back, and
|
||||
* whether a row is new is the row's weight to carry, not a heading's: two groups over a dot over a
|
||||
* weight was three signals for one bit, and read as a bug whenever one of them lagged.
|
||||
*/
|
||||
export const GROUPS: Record<string, string> = {
|
||||
back: "Back",
|
||||
today: "Today",
|
||||
"this-week": "This week",
|
||||
"this-month": "This month",
|
||||
earlier: "Earlier",
|
||||
"sent-to": "Sent to",
|
||||
};
|
||||
|
||||
export interface ThreadPage {
|
||||
threads: ThreadSummary[];
|
||||
nextCursor: string | null;
|
||||
/** The quiet line at the foot, such as "Showing the last month. Older mail is on Gmail." */
|
||||
footer: string | null;
|
||||
}
|
||||
|
||||
export interface MergedSource {
|
||||
key: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface ThreadView {
|
||||
key: string;
|
||||
accountId: string;
|
||||
subject: string;
|
||||
originalSubject: string | null;
|
||||
participants: Person[];
|
||||
messages: MessageView[];
|
||||
notes: Note[];
|
||||
mergedFrom: MergedSource[];
|
||||
pile: Pile | null;
|
||||
snoozedUntil: number | null;
|
||||
ignored: boolean;
|
||||
notify: boolean;
|
||||
starred: boolean;
|
||||
/** The same two flags the row carries, so the pane can offer the way back out. */
|
||||
trashed: boolean;
|
||||
spam: boolean;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Messages
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
inline: boolean;
|
||||
contentId: string | null;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
vendor: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Unsubscribe {
|
||||
oneClick: boolean;
|
||||
mailto: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export type InviteResponse = "accepted" | "tentative" | "declined" | "needs-action";
|
||||
|
||||
export interface Invite {
|
||||
uid: string;
|
||||
summary: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
organizer: Person | null;
|
||||
description: string | null;
|
||||
myResponse: InviteResponse;
|
||||
calendarLink: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which surface a message body renders on.
|
||||
*
|
||||
* `theme` is the app's own paper, light or dark with everything else. `paper` is the light page in
|
||||
* both palettes, for a sender who painted one. Rust decides it from what the message paints, not
|
||||
* from how it arrived, and stores the answer on the body row.
|
||||
*/
|
||||
export type Surface = "theme" | "paper";
|
||||
|
||||
/**
|
||||
* One message, sanitised and ready to render.
|
||||
*
|
||||
* `html` goes into the iframe's `srcdoc`. It has been through the sanitiser, so `cid:` images are
|
||||
* already `data:` URIs and every remote image is either removed or, once the user has asked for
|
||||
* them, fetched by Rust and inlined the same way. The frontend never fetches anything.
|
||||
*/
|
||||
export interface MessageView {
|
||||
/**
|
||||
* The provider's message id. Every command argument called `messageId` is this one, never the
|
||||
* header below it: the RFC `Message-ID` is what the state database keys on and it never crosses
|
||||
* this boundary as an argument.
|
||||
*/
|
||||
id: string;
|
||||
/** The RFC `Message-ID`, which is what the state database keys on. */
|
||||
messageId: string;
|
||||
threadKey: string;
|
||||
from: Person;
|
||||
to: Person[];
|
||||
cc: Person[];
|
||||
bcc: Person[];
|
||||
replyTo: Person[];
|
||||
dateMs: number;
|
||||
subject: string;
|
||||
html: string;
|
||||
/**
|
||||
* The body has not been fetched yet, so `html` is empty because there is nothing to show rather
|
||||
* than because the message was. Opening a thread never waits on the network: what the mirror has
|
||||
* comes back at once and the rest arrives on a later `store-changed`.
|
||||
*/
|
||||
bodyPending: boolean;
|
||||
quotedHtml: string | null;
|
||||
isHtml: boolean;
|
||||
/**
|
||||
* The surface the body reads on. Not the same question as `isHtml`: what decides it is whether
|
||||
* the sender painted a page, and most HTML mail paints nothing.
|
||||
*/
|
||||
surface: Surface;
|
||||
attachments: Attachment[];
|
||||
trackers: Tracker[];
|
||||
blockedImages: number;
|
||||
imagesLoaded: boolean;
|
||||
seen: boolean;
|
||||
draft: boolean;
|
||||
sentByMe: boolean;
|
||||
invite: Invite | null;
|
||||
unsubscribe: Unsubscribe | null;
|
||||
listId: string | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// The decisions: rules, piles, snoozes, notes, clips
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface SenderRule {
|
||||
accountId: string;
|
||||
/** An address, or a domain when `isDomain`. */
|
||||
subject: string;
|
||||
isDomain: boolean;
|
||||
destination: Destination;
|
||||
decidedAtMs: number;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenerCard {
|
||||
accountId: string;
|
||||
sender: Person;
|
||||
threadKey: string;
|
||||
subject: string;
|
||||
snippet: string;
|
||||
dateMs: number;
|
||||
suggestion: Destination;
|
||||
/** The sentence the card prints, which is the row of the rules table that matched. */
|
||||
reason: string;
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
export type SnoozeKind =
|
||||
| "later-today"
|
||||
| "tomorrow"
|
||||
| "weekend"
|
||||
| "next-week"
|
||||
| "date"
|
||||
| "if-no-reply";
|
||||
|
||||
export interface Snooze {
|
||||
threadKey: string;
|
||||
returnAtMs: number;
|
||||
kind: SnoozeKind;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
threadKey: string;
|
||||
body: string;
|
||||
createdAtMs: number;
|
||||
afterMessageId: string | null;
|
||||
}
|
||||
|
||||
export interface Clip {
|
||||
id: string;
|
||||
accountId: string;
|
||||
threadKey: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
sender: Person;
|
||||
subject: string;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
person: Person;
|
||||
accountId: string;
|
||||
destination: Destination;
|
||||
domainRule: boolean;
|
||||
/** A consumer domain cannot carry a domain rule, so the toggle is not offered. */
|
||||
domainRuleAllowed: boolean;
|
||||
notify: boolean;
|
||||
screenedAtMs: number | null;
|
||||
note: string | null;
|
||||
allowRemoteImages: boolean;
|
||||
autoTrashDays: number | null;
|
||||
bundle: boolean;
|
||||
recentThreads: ThreadSummary[];
|
||||
files: Attachment[];
|
||||
unsubscribe: Unsubscribe | null;
|
||||
}
|
||||
|
||||
/** An absent field means unchanged. */
|
||||
export interface ContactPatch {
|
||||
destination?: Destination;
|
||||
domainRule?: boolean;
|
||||
notify?: boolean;
|
||||
note?: string;
|
||||
allowRemoteImages?: boolean;
|
||||
autoTrashDays?: number | null;
|
||||
bundle?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface DraftAttachment {
|
||||
path?: string | null;
|
||||
attachmentId?: string | null;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
id?: string | null;
|
||||
accountId: string;
|
||||
threadKey?: string | null;
|
||||
inReplyTo?: string | null;
|
||||
fromAlias?: string | null;
|
||||
to: Person[];
|
||||
cc?: Person[];
|
||||
bcc?: Person[];
|
||||
subject: string;
|
||||
/** The editor's HTML. Rust inlines the stylesheet and builds the plain text alternative. */
|
||||
bodyHtml: string;
|
||||
attachments?: DraftAttachment[];
|
||||
remindAtMs?: number | null;
|
||||
}
|
||||
|
||||
export interface DraftSaved {
|
||||
id: string;
|
||||
updatedAtMs: number;
|
||||
encodedSize: number;
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
export interface Outgoing {
|
||||
id: string;
|
||||
accountId: string;
|
||||
threadKey: string | null;
|
||||
to: Person[];
|
||||
subject: string;
|
||||
holdUntilMs: number;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Undo
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What a mutating command hands back so `z` can take it back. The token is a handle on the state
|
||||
* before the change, held in a bounded stack in Rust, because a bulk archive of forty threads with
|
||||
* mixed prior state cannot be reversed from what the frontend knew.
|
||||
*/
|
||||
export interface Undo {
|
||||
token: string;
|
||||
label: string;
|
||||
/** Zero for anything but a send. A send's toast counts down. */
|
||||
undoMs: number;
|
||||
}
|
||||
|
||||
/** The flags a triage action sets. An absent field means unchanged. */
|
||||
export interface FlagPatch {
|
||||
seen?: boolean;
|
||||
starred?: boolean;
|
||||
archived?: boolean;
|
||||
trashed?: boolean;
|
||||
spam?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Search, labels, files
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface SearchResult {
|
||||
page: ThreadPage;
|
||||
providerSearched: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface LabelInfo {
|
||||
id: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface FileCard {
|
||||
attachment: Attachment;
|
||||
threadKey: string;
|
||||
subject: string;
|
||||
sender: Person;
|
||||
dateMs: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Settings
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** margin-shared's `FontRef`: one of the six bundled faces, or a family off the machine. */
|
||||
export type FontRef = { kind: "bundled"; id: string } | { kind: "system"; family: string };
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// IMAP and SMTP configuration
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How a socket is protected, in the vocabulary every published mail configuration is written in.
|
||||
*
|
||||
* `tls` is what those documents call SSL: TLS from the first byte, on 993 or 465. `starttls` is a
|
||||
* plaintext connection upgraded by a command, on 143 or 587.
|
||||
*/
|
||||
export type Security = "plain" | "start-tls" | "tls";
|
||||
|
||||
export type AuthKind = "password" | "o-auth2";
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
security: Security;
|
||||
auth: AuthKind;
|
||||
/** Already expanded: discovery substitutes the address placeholders before this is returned. */
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface MailConfig {
|
||||
imap: ServerConfig;
|
||||
smtp: ServerConfig;
|
||||
/**
|
||||
* Which rung of the ladder answered: `autoconfig`, `ispdb`, `mx`, `probe` or `manual`. The
|
||||
* connect screen says where the settings came from, because "we found these" and "we guessed
|
||||
* these" are different promises and somebody about to type a password should be told which.
|
||||
*/
|
||||
source: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A certificate that has to be decided about before a connection can be made.
|
||||
*
|
||||
* Only ever raised for a host that is not the loopback. A local bridge listens on 127.0.0.1 with
|
||||
* a certificate it generated itself and there is nothing in between to impersonate anybody, so
|
||||
* loopback is trusted without asking.
|
||||
*/
|
||||
export interface CertQuestion {
|
||||
host: string;
|
||||
port: number;
|
||||
/** SHA-256 of the certificate, in the colon-separated form every other tool prints. */
|
||||
fingerprint: string;
|
||||
subject: string;
|
||||
issuer: string;
|
||||
expiresMs: number;
|
||||
/** `self-signed`, `expired` or `unknown-issuer`. A name mismatch is refused, never offered. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a connection test found.
|
||||
*
|
||||
* Not a thrown error, because the interesting outcomes are all things the screen draws. A wrong
|
||||
* password, a certificate to decide about and a host that does not answer are three panels.
|
||||
*/
|
||||
/**
|
||||
* What kind of refusal it was.
|
||||
*
|
||||
* A field rather than something the screen reads back out of the message, because real decisions
|
||||
* hang off it: a loopback that is unreachable means the bridge is not running, which is a
|
||||
* different sentence from a wrong password, and telling them apart by matching prose means a
|
||||
* regex over whatever the server happened to say that day.
|
||||
*/
|
||||
export type RefusalKind = "unreachable" | "auth" | "certificate" | "wrong-host" | "other";
|
||||
|
||||
export interface ConnectReport {
|
||||
ok: boolean;
|
||||
kind: RefusalKind | null;
|
||||
/** `imap` or `smtp`, when one leg failed and the other did not. */
|
||||
failed: string | null;
|
||||
message: string | null;
|
||||
cert: CertQuestion | null;
|
||||
/** A sentence naming what to go and do, when the server said enough to know. */
|
||||
advice: string | null;
|
||||
}
|
||||
|
||||
/** The default port for a security. Both halves agree on the shape and differ on the numbers. */
|
||||
export function defaultPort(leg: "imap" | "smtp", security: Security): number {
|
||||
if (leg === "imap") return security === "tls" ? 993 : 143;
|
||||
if (security === "tls") return 465;
|
||||
return security === "start-tls" ? 587 : 25;
|
||||
}
|
||||
|
||||
export interface AccountSettings {
|
||||
accountId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** 30, 90, 180, 365, or 0 for everything. */
|
||||
windowDays: number;
|
||||
signature: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface SnoozeTimes {
|
||||
laterTodayHours: number;
|
||||
tomorrowAt: number;
|
||||
weekendAt: number;
|
||||
nextWeekAt: number;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
store: "none" | "drive" | "r2";
|
||||
configured: boolean;
|
||||
lastBackupMs: number | null;
|
||||
hasPhrase: boolean;
|
||||
r2Bucket: string | null;
|
||||
r2Endpoint: string | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme: "light" | "dark" | "system";
|
||||
fontUi: FontRef;
|
||||
fontText: FontRef;
|
||||
textSize: number;
|
||||
readingPane: boolean;
|
||||
density: "comfortable" | "compact";
|
||||
|
||||
accounts: AccountSettings[];
|
||||
attachmentCacheMb: number;
|
||||
prefetchBodies: boolean;
|
||||
|
||||
/** The per sender allowances are not here: they live on the contact and roam with it. */
|
||||
remoteImages: "never" | "ask" | "always";
|
||||
linkCleaning: boolean;
|
||||
|
||||
screenerEnabled: boolean;
|
||||
holdReplies: boolean;
|
||||
suggestions: boolean;
|
||||
|
||||
snoozeTimes: SnoozeTimes;
|
||||
swipeRight: string;
|
||||
swipeLeft: string;
|
||||
feedAutoTrashDays: number;
|
||||
|
||||
undoDelaySecs: number;
|
||||
replyAllDefault: boolean;
|
||||
instantIntro: string;
|
||||
|
||||
badge: boolean;
|
||||
/** The switch over everything below: off, and nothing is posted whatever a thread, a person or a place says. */
|
||||
notifications: boolean;
|
||||
/** The places that notify without being asked thread by thread. Empty by default. */
|
||||
notifyPlaces: Place[];
|
||||
|
||||
backup: BackupSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the system will show this app's notifications: what System Settings says on macOS, and
|
||||
* "prompt" until the app has asked once. Everywhere else the answer is "granted".
|
||||
*/
|
||||
export type NotifyPermission = "granted" | "denied" | "prompt";
|
||||
|
||||
/**
|
||||
* Where a click on a notification goes: the account it was about, the place its thread shows in,
|
||||
* and the thread when the notification was about one. Several in one pass go to the place alone.
|
||||
*/
|
||||
export interface NotifyTarget {
|
||||
accountId: string;
|
||||
place: Place;
|
||||
threadKey: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface StorageUsed {
|
||||
accountId: string;
|
||||
messages: number;
|
||||
threads: number;
|
||||
mirrorBytes: number;
|
||||
bodiesBytes: number;
|
||||
attachmentsBytes: number;
|
||||
stateBytes: number;
|
||||
oldestMs: number | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Sync, auth and events
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export interface SyncStatus {
|
||||
accountId: string;
|
||||
phase:
|
||||
| "idle"
|
||||
| "syncing"
|
||||
| "hydrating"
|
||||
// Bodies being brought in behind a finished first sync, so a thread opens out of the mirror
|
||||
// rather than off the network. Quiet: the mail is already readable, this is only the wait
|
||||
// going away.
|
||||
| "caching"
|
||||
| "backfilling"
|
||||
| "offline"
|
||||
| "error"
|
||||
| "paused";
|
||||
lastSyncMs: number | null;
|
||||
error: string | null;
|
||||
pendingWrites: number;
|
||||
message: string | null;
|
||||
hydrated: number;
|
||||
total: number;
|
||||
oldestMs: number | null;
|
||||
}
|
||||
|
||||
/** Payload of `sync-progress`. */
|
||||
export type SyncProgress = SyncStatus;
|
||||
|
||||
/** Payload of the `auth` event. */
|
||||
export interface AuthEvent {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
accountId: string | null;
|
||||
email: string | null;
|
||||
cancelled: boolean;
|
||||
grantedScopes: string[];
|
||||
missingRequired: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of `store-changed`: a scope naming what moved, so a note landing does not make the list
|
||||
* refetch its bodies. One of `threads`, `thread:<key>`, `accounts`, `settings`, `state`, `screener`,
|
||||
* `outbox`, or several separated by a space.
|
||||
*/
|
||||
export type StoreChanged = string;
|
||||
|
||||
/** The scopes the app asks for, in the order the consent screen lists them. */
|
||||
export const SCOPES = [
|
||||
"openid",
|
||||
"email",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.settings.basic",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.other.readonly",
|
||||
] as const;
|
||||
|
||||
/** Answering an invite needs this one, and it is asked for by re-running the whole consent. */
|
||||
export const CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
||||
|
||||
/** Backing up to Drive needs this one, asked for the same way. */
|
||||
export const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
||||
|
||||
/** Without this the account is not added at all, and the app says why. */
|
||||
export const REQUIRED_SCOPE = "https://www.googleapis.com/auth/gmail.modify";
|
||||
|
||||
/**
|
||||
* In Tauri this is `invoke`. Opened in a browser during development it is served from the dev
|
||||
* fixture instead, so the real UI can be driven and looked at without a Google account. The branch
|
||||
* is compiled out of a production bundle, and `isTauri` means it can never shadow the real backend
|
||||
* inside the app, on a desktop or on a phone.
|
||||
*/
|
||||
export function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return invoke<T>(command, args);
|
||||
if (import.meta.env.DEV && !isTauri) {
|
||||
return import("./dev/mockIpc").then((m) => m.mockCall<T>(command, args));
|
||||
}
|
||||
return invoke<T>(command, args).catch((e: unknown) => {
|
||||
// Written down before it becomes a toast, so the log holds what the person saw and not
|
||||
// only what the engine did on its own. Not for the note itself, which would loop.
|
||||
if (command !== "log_note") {
|
||||
void invoke("log_note", { who: "ui", line: `${command}: ${String(e)}` }).catch(() => {});
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// The `?` sheet, generated from the binding table. There is no list of shortcuts anywhere in this
|
||||
// file, which is the entire point: a binding that exists is documented, and one that is removed
|
||||
// stops being documented, without anybody remembering to do either.
|
||||
//
|
||||
// It takes `open` and `onClose` as props rather than reading the overlay store, so this module
|
||||
// stays a function of the table and nothing else.
|
||||
|
||||
import { Key, Sheet } from "../ui";
|
||||
import { BINDINGS, GROUPS, keyLabel } from "./bindings";
|
||||
import "./shortcuts.css";
|
||||
|
||||
export interface ShortcutsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShortcutsSheet({ open, onClose }: ShortcutsSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} title="Keyboard shortcuts" size="wide" onClose={onClose}>
|
||||
<div className="shortcuts">
|
||||
{GROUPS.map((group) => {
|
||||
// A binding with no keys is a palette row rather than a shortcut, and a sheet of
|
||||
// shortcuts that lists one with no keycap beside it is a sheet that has lost the plot.
|
||||
const rows = BINDINGS.filter((binding) => binding.group === group && binding.keys.length > 0);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section className="shortcuts-group" key={group}>
|
||||
<h3 className="shortcuts-heading">{group}</h3>
|
||||
<dl className="shortcuts-list">
|
||||
{rows.map((binding) => (
|
||||
<div className="shortcuts-row" key={`${binding.context}:${binding.keys.join(" ")}`}>
|
||||
<dt className="shortcuts-keys">
|
||||
{binding.keys.map((key) => (
|
||||
<Key key={key}>{keyLabel(key)}</Key>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="shortcuts-label">{binding.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="shortcuts-note">
|
||||
Nothing is modal and nothing is chorded. Keys stand back while a text field has the focus,
|
||||
except the palette, which is how you get out of anywhere.
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShortcutsSheet;
|
||||
@@ -0,0 +1,95 @@
|
||||
// The table's own invariants. Two bindings on one combo in one context would silently shadow each
|
||||
// other, and a group the sheet does not render would silently hide a key, so both are asserted here
|
||||
// rather than discovered later.
|
||||
//
|
||||
// This imports the table alone, which is why the labels live on the bindings: `commands.ts` reaches
|
||||
// for the stores and for Tauri the moment it is loaded.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_KEYS,
|
||||
BINDINGS,
|
||||
GROUPS,
|
||||
keyLabel,
|
||||
keysFor,
|
||||
normalizeCombo,
|
||||
} from "./bindings";
|
||||
|
||||
describe("the binding table", () => {
|
||||
it("never binds one combo twice in the same context", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const binding of BINDINGS) {
|
||||
for (const key of binding.keys) {
|
||||
const slot = `${binding.context}:${normalizeCombo(key)}`;
|
||||
expect(seen.has(slot), `${slot} is bound twice`).toBe(false);
|
||||
seen.add(slot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("puts every binding in a group the sheet renders", () => {
|
||||
for (const binding of BINDINGS) expect(GROUPS).toContain(binding.group);
|
||||
});
|
||||
|
||||
it("gives every binding a label, including the ones it does not own", () => {
|
||||
for (const binding of BINDINGS) expect(binding.label).not.toBe("");
|
||||
});
|
||||
|
||||
it("documents Escape without claiming to handle it", () => {
|
||||
const escape = BINDINGS.find((b) => b.keys.includes("Escape"));
|
||||
expect(escape?.command).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the editor's own keys to the editor", () => {
|
||||
for (const combo of ["cmd+b", "cmd+i", "cmd+k"]) {
|
||||
const inEditor = BINDINGS.find((b) => b.context === "editor" && b.keys.includes(combo));
|
||||
expect(inEditor?.command, `${combo} in the editor`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the palette reachable from a text field", () => {
|
||||
const palette = BINDINGS.find((b) => b.command === "command-palette");
|
||||
expect(palette?.allowInInput).toBe(true);
|
||||
expect(palette?.context).toBe("global");
|
||||
});
|
||||
|
||||
it("reuses y, v and n only on the cards docs/keyboard.md allows", () => {
|
||||
for (const key of ["y", "v", "n"]) {
|
||||
const contexts = BINDINGS.filter((b) => b.keys.includes(key)).map((b) => b.context);
|
||||
for (const context of contexts) {
|
||||
expect(["view", "screener", "invite"]).toContain(context);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let the account keys collide with a place key", () => {
|
||||
for (const combo of ACCOUNT_KEYS) {
|
||||
const clash = BINDINGS.find((b) => b.keys.map(normalizeCombo).includes(combo));
|
||||
expect(clash, `${combo} is both an account and a binding`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Cmd+A and Cmd+Shift+A apart", () => {
|
||||
expect(normalizeCombo("cmd+a")).not.toBe(normalizeCombo("cmd+shift+a"));
|
||||
expect(keysFor("select-all").map(normalizeCombo)).toContain("cmd+a");
|
||||
expect(keysFor("attach").map(normalizeCombo)).toContain("cmd+shift+a");
|
||||
});
|
||||
|
||||
it("reads a combo the same way the dispatcher builds one", () => {
|
||||
expect(normalizeCombo("Cmd+K")).toBe("cmd+k");
|
||||
expect(normalizeCombo("cmd+k")).toBe("cmd+k");
|
||||
expect(normalizeCombo("H")).toBe("shift+h");
|
||||
expect(normalizeCombo("shift+h")).toBe("shift+h");
|
||||
expect(normalizeCombo("/")).toBe("/");
|
||||
expect(normalizeCombo("shift+Tab")).toBe("shift+Tab");
|
||||
expect(normalizeCombo("shift+ ")).toBe("shift+ ");
|
||||
});
|
||||
|
||||
it("prints a key the way a keycap does", () => {
|
||||
expect(keyLabel("shift+h")).toBe("⇧H");
|
||||
expect(keyLabel("h")).toBe("h".toUpperCase());
|
||||
expect(keyLabel("Enter")).toBe("↩");
|
||||
expect(keyLabel("Escape")).toBe("⎋");
|
||||
expect(keyLabel(" ")).toBe("Space");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
// The keymap, declared once, and the command ids it dispatches.
|
||||
//
|
||||
// `keymap.ts` dispatches from this table, `Shortcuts.tsx` renders the `?` sheet from it, and the
|
||||
// palette lists it, so a binding that exists but is undocumented is not something you can write:
|
||||
// the sheet is generated, never maintained.
|
||||
//
|
||||
// The command ids and their labels live here rather than in `commands.ts` on purpose. `commands.ts`
|
||||
// reaches for the stores and for Tauri the moment it is loaded, and the sheet, the palette and this
|
||||
// table's own tests all want to name a command without starting the app.
|
||||
//
|
||||
// A combo is canonical: modifiers in `cmd+ctrl+alt` order, then `KeyboardEvent.key` verbatim. `cmd`
|
||||
// means the platform's primary modifier, Command on macOS and Control everywhere else, which is
|
||||
// what the native menu's `CmdOrCtrl` accelerators mean too. Shift is not a modifier here: it is
|
||||
// already baked into the key, so `H` is the shifted `h` and reads that way in the table.
|
||||
//
|
||||
// Nothing is chorded and nothing is modal. Two keys never combine into a third meaning.
|
||||
|
||||
/**
|
||||
* Which frame of the context stack a binding belongs to.
|
||||
*
|
||||
* `view` is the list and the pane, which is where most of the app lives. The three card contexts
|
||||
* exist for the one exception docs/keyboard.md allows: `y`, `v` and `n` mean one thing on a
|
||||
* Screener card, another on an invite, and a third in the list, and the card is the only thing that
|
||||
* can receive them. `editor` carries no commands of ours; it exists so the editor's own keys are
|
||||
* documented and so this dispatcher stands out of TipTap's way. `overlay` carries none either:
|
||||
* pushing it is how an open panel shadows the whole view keymap while leaving `global` reachable.
|
||||
*/
|
||||
export type KeyContext =
|
||||
| "global"
|
||||
| "view"
|
||||
| "screener"
|
||||
| "invite"
|
||||
| "focus"
|
||||
| "editor"
|
||||
| "overlay";
|
||||
|
||||
export type BindingGroup =
|
||||
| "Navigation"
|
||||
| "Places"
|
||||
| "Triage"
|
||||
| "Screener"
|
||||
| "Reading"
|
||||
| "Writing"
|
||||
| "Focus & Reply"
|
||||
| "App";
|
||||
|
||||
export type CommandId =
|
||||
// navigation
|
||||
| "select-next"
|
||||
| "select-prev"
|
||||
| "open-selection"
|
||||
| "message-next"
|
||||
| "message-prev"
|
||||
| "message-toggle"
|
||||
| "message-expand-all"
|
||||
| "search"
|
||||
| "toggle-pane"
|
||||
// places
|
||||
| "place-inbox"
|
||||
| "place-feed"
|
||||
| "place-paper-trail"
|
||||
| "place-reply-later"
|
||||
| "place-set-aside"
|
||||
| "place-screener"
|
||||
| "place-snoozed"
|
||||
| "place-everything"
|
||||
// triage
|
||||
| "archive"
|
||||
| "toggle-seen"
|
||||
| "toggle-star"
|
||||
| "trash"
|
||||
| "spam"
|
||||
| "reply-later"
|
||||
| "set-aside"
|
||||
| "snooze"
|
||||
| "note"
|
||||
| "ignore"
|
||||
| "notify"
|
||||
| "merge"
|
||||
| "rename"
|
||||
| "contact-card"
|
||||
| "label"
|
||||
| "move"
|
||||
| "unsubscribe"
|
||||
| "select"
|
||||
| "select-extend-down"
|
||||
| "select-extend-up"
|
||||
| "select-all"
|
||||
| "undo"
|
||||
| "more"
|
||||
| "mark-all-seen"
|
||||
// screener
|
||||
| "screen-yes"
|
||||
| "screen-elsewhere"
|
||||
| "screen-no"
|
||||
| "screen-reply"
|
||||
// invites
|
||||
| "invite-accept"
|
||||
| "invite-maybe"
|
||||
| "invite-decline"
|
||||
// writing
|
||||
| "compose"
|
||||
| "reply"
|
||||
| "reply-all"
|
||||
| "forward"
|
||||
| "send"
|
||||
| "send-now"
|
||||
| "compose-expand"
|
||||
| "attach"
|
||||
| "instant-intro"
|
||||
| "remind-if-no-reply"
|
||||
| "save-clip"
|
||||
| "discard-draft"
|
||||
// focus and reply
|
||||
| "focus-reply"
|
||||
| "focus-next"
|
||||
| "focus-prev"
|
||||
// the libraries
|
||||
| "open-contacts"
|
||||
| "open-clips"
|
||||
| "open-files"
|
||||
// app
|
||||
| "command-palette"
|
||||
| "shortcuts"
|
||||
| "tour"
|
||||
| "guide"
|
||||
| "settings"
|
||||
| "sync-now"
|
||||
| "accounts"
|
||||
| "account-next"
|
||||
| "toggle-theme"
|
||||
| "check-updates"
|
||||
| "report-issue";
|
||||
|
||||
interface BindingBase {
|
||||
/** Every combo that runs it. The sheet shows them all; the dispatcher accepts any. */
|
||||
keys: readonly string[];
|
||||
context: KeyContext;
|
||||
group: BindingGroup;
|
||||
/** Off by default: a key must never be stolen from a text field. */
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandBinding extends BindingBase {
|
||||
command: CommandId;
|
||||
/** The words the sheet, the palette and any tooltip all use. */
|
||||
label: string;
|
||||
/** Whether the palette lists it. Moving one row at a time is a key, not a menu entry. */
|
||||
palette?: boolean;
|
||||
}
|
||||
|
||||
/** A key the keymap deliberately does not own, documented so the sheet is not a half-truth. */
|
||||
export interface NoteBinding extends BindingBase {
|
||||
command: null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type Binding = CommandBinding | NoteBinding;
|
||||
|
||||
export const BINDINGS: readonly Binding[] = [
|
||||
// -- Navigation ---------------------------------------------------------------------------
|
||||
{ keys: ["j"], command: "select-next", label: "Next thread", context: "view", group: "Navigation" },
|
||||
{ keys: ["k"], command: "select-prev", label: "Previous thread", context: "view", group: "Navigation" },
|
||||
{
|
||||
keys: ["Enter"],
|
||||
command: "open-selection",
|
||||
label: "Open the thread, or read the selection together",
|
||||
context: "view",
|
||||
group: "Navigation",
|
||||
},
|
||||
{ keys: ["n"], command: "message-next", label: "Next message", context: "view", group: "Reading" },
|
||||
{ keys: ["p"], command: "message-prev", label: "Previous message", context: "view", group: "Reading" },
|
||||
{
|
||||
keys: ["o"],
|
||||
command: "message-toggle",
|
||||
label: "Expand or collapse this message",
|
||||
context: "view",
|
||||
group: "Reading",
|
||||
},
|
||||
{
|
||||
keys: ["shift+o"],
|
||||
command: "message-expand-all",
|
||||
label: "Expand every message",
|
||||
context: "view",
|
||||
group: "Reading",
|
||||
},
|
||||
{
|
||||
keys: [" "],
|
||||
command: null,
|
||||
label: "Scroll the reading pane down",
|
||||
context: "view",
|
||||
group: "Reading",
|
||||
},
|
||||
{
|
||||
keys: ["shift+ "],
|
||||
command: null,
|
||||
label: "Scroll the reading pane up",
|
||||
context: "view",
|
||||
group: "Reading",
|
||||
},
|
||||
{ keys: ["/"], command: "search", label: "Search", context: "view", group: "Navigation", palette: true },
|
||||
{
|
||||
keys: ["cmd+\\"],
|
||||
command: "toggle-pane",
|
||||
label: "Show or hide the reading pane",
|
||||
context: "view",
|
||||
group: "Navigation",
|
||||
palette: true,
|
||||
},
|
||||
|
||||
// -- Places -------------------------------------------------------------------------------
|
||||
{ keys: ["1", "cmd+1"], command: "place-inbox", label: "Inbox", context: "view", group: "Places", palette: true },
|
||||
{ keys: ["2", "cmd+2"], command: "place-feed", label: "Feed", context: "view", group: "Places", palette: true },
|
||||
{
|
||||
keys: ["3", "cmd+3"],
|
||||
command: "place-paper-trail",
|
||||
label: "Paper Trail",
|
||||
context: "view",
|
||||
group: "Places",
|
||||
palette: true,
|
||||
},
|
||||
{ keys: ["4"], command: "place-reply-later", label: "Reply later", context: "view", group: "Places", palette: true },
|
||||
{ keys: ["5"], command: "place-set-aside", label: "Set aside", context: "view", group: "Places", palette: true },
|
||||
{ keys: ["6"], command: "place-screener", label: "Screener", context: "view", group: "Places", palette: true },
|
||||
{ keys: ["7"], command: "place-snoozed", label: "Snoozed", context: "view", group: "Places", palette: true },
|
||||
{ keys: ["0"], command: "place-everything", label: "Everything", context: "view", group: "Places", palette: true },
|
||||
|
||||
// -- Triage -------------------------------------------------------------------------------
|
||||
{ keys: ["e"], command: "archive", label: "Archive", context: "view", group: "Triage" },
|
||||
{ keys: ["u"], command: "toggle-seen", label: "Mark seen or unseen", context: "view", group: "Triage" },
|
||||
{ keys: ["shift+s"], command: "toggle-star", label: "Star", context: "view", group: "Triage" },
|
||||
{ keys: ["#", "shift+#"], command: "trash", label: "Trash", context: "view", group: "Triage" },
|
||||
{ keys: ["!", "shift+!"], command: "spam", label: "Mark as spam", context: "view", group: "Triage" },
|
||||
{ keys: ["l"], command: "reply-later", label: "Reply later", context: "view", group: "Triage" },
|
||||
{ keys: ["s"], command: "set-aside", label: "Set aside", context: "view", group: "Triage" },
|
||||
{ keys: ["b"], command: "snooze", label: "Snooze", context: "view", group: "Triage" },
|
||||
{ keys: ["y"], command: "note", label: "Add a note", context: "view", group: "Triage" },
|
||||
{ keys: ["m"], command: "ignore", label: "Ignore this thread", context: "view", group: "Triage" },
|
||||
{ keys: ["shift+n"], command: "notify", label: "Notify me on this thread", context: "view", group: "Triage" },
|
||||
{ keys: ["g"], command: "merge", label: "Merge the selected threads", context: "view", group: "Triage" },
|
||||
{ keys: ["i"], command: "contact-card", label: "Contact card", context: "view", group: "Triage" },
|
||||
{ keys: ["shift+l"], command: "label", label: "Label", context: "view", group: "Triage" },
|
||||
{ keys: ["v"], command: "move", label: "Move to Inbox, Feed or Paper Trail", context: "view", group: "Triage" },
|
||||
{ keys: ["cmd+u"], command: "unsubscribe", label: "Unsubscribe", context: "view", group: "Triage" },
|
||||
{ keys: ["x"], command: "select", label: "Select this thread", context: "view", group: "Triage" },
|
||||
{ keys: ["shift+j"], command: "select-extend-down", label: "Extend the selection down", context: "view", group: "Triage" },
|
||||
{ keys: ["shift+k"], command: "select-extend-up", label: "Extend the selection up", context: "view", group: "Triage" },
|
||||
{ keys: ["cmd+a"], command: "select-all", label: "Select all from here", context: "view", group: "Triage" },
|
||||
{ keys: ["z"], command: "undo", label: "Undo", context: "view", group: "Triage", palette: true },
|
||||
{ keys: ["."], command: "more", label: "More actions", context: "view", group: "Triage" },
|
||||
|
||||
// -- The Screener -------------------------------------------------------------------------
|
||||
{ keys: ["y"], command: "screen-yes", label: "Yes, to the suggested place", context: "screener", group: "Screener" },
|
||||
{
|
||||
keys: ["v"],
|
||||
command: "screen-elsewhere",
|
||||
label: "Elsewhere: pick the place",
|
||||
context: "screener",
|
||||
group: "Screener",
|
||||
},
|
||||
{ keys: ["n"], command: "screen-no", label: "No, screen them out", context: "screener", group: "Screener" },
|
||||
{
|
||||
keys: ["r"],
|
||||
command: "screen-reply",
|
||||
label: "Screen in to the Inbox and reply",
|
||||
context: "screener",
|
||||
group: "Screener",
|
||||
},
|
||||
|
||||
// -- Invites ------------------------------------------------------------------------------
|
||||
{ keys: ["y"], command: "invite-accept", label: "Accept the invitation", context: "invite", group: "Reading" },
|
||||
{ keys: ["m"], command: "invite-maybe", label: "Maybe", context: "invite", group: "Reading" },
|
||||
{ keys: ["n"], command: "invite-decline", label: "Decline", context: "invite", group: "Reading" },
|
||||
|
||||
// -- Writing ------------------------------------------------------------------------------
|
||||
{ keys: ["c", "cmd+n"], command: "compose", label: "New message", context: "view", group: "Writing", palette: true },
|
||||
{ keys: ["r"], command: "reply", label: "Reply", context: "view", group: "Writing" },
|
||||
{ keys: ["a"], command: "reply-all", label: "Reply all", context: "view", group: "Writing" },
|
||||
{ keys: ["f"], command: "forward", label: "Forward", context: "view", group: "Writing" },
|
||||
{
|
||||
keys: ["cmd+Enter"],
|
||||
command: "send",
|
||||
label: "Send",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+Enter"],
|
||||
command: "send-now",
|
||||
label: "Send now, with no undo",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+p"],
|
||||
command: "compose-expand",
|
||||
label: "Expand the compose card to the window",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+a"],
|
||||
command: "attach",
|
||||
label: "Attach a file",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+i"],
|
||||
command: "instant-intro",
|
||||
label: "Instant intro: move the introducer to Bcc and thank them",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+h"],
|
||||
command: "remind-if-no-reply",
|
||||
label: "Remind me if no reply",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+c"],
|
||||
command: "save-clip",
|
||||
label: "Save the selection as a clip",
|
||||
context: "global",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+shift+,", "cmd+shift+<"],
|
||||
command: "discard-draft",
|
||||
label: "Discard the draft",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
// The editor's own keys. Ours to document and TipTap's to handle, which is why the command is
|
||||
// null: the dispatcher sees the frame, finds no command, and stands out of the way. Cmd+K is the
|
||||
// one real collision in the app, and inside the editor the link wins, because a palette is one
|
||||
// Escape away and a link is not.
|
||||
{
|
||||
keys: ["cmd+b"],
|
||||
command: null,
|
||||
label: "Bold",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+i"],
|
||||
command: null,
|
||||
label: "Italic",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
{
|
||||
keys: ["cmd+k"],
|
||||
command: null,
|
||||
label: "Link",
|
||||
context: "editor",
|
||||
group: "Writing",
|
||||
allowInInput: true,
|
||||
},
|
||||
|
||||
// -- Focus & Reply ------------------------------------------------------------------------
|
||||
{
|
||||
keys: ["shift+f"],
|
||||
command: "focus-reply",
|
||||
label: "Focus & Reply",
|
||||
context: "view",
|
||||
group: "Focus & Reply",
|
||||
palette: true,
|
||||
},
|
||||
{ keys: ["Tab"], command: "focus-next", label: "Next item", context: "focus", group: "Focus & Reply" },
|
||||
// The three libraries. No keys: the number keys are the seven daily places and these are not
|
||||
// daily, so the palette is how they are reached and the palette is enough.
|
||||
{
|
||||
keys: [],
|
||||
command: "open-contacts",
|
||||
label: "Contacts",
|
||||
context: "view",
|
||||
group: "Places",
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: [],
|
||||
command: "open-clips",
|
||||
label: "Clips",
|
||||
context: "view",
|
||||
group: "Places",
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: [],
|
||||
command: "open-files",
|
||||
label: "All files",
|
||||
context: "view",
|
||||
group: "Places",
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: ["shift+Tab"],
|
||||
command: "focus-prev",
|
||||
label: "Previous item",
|
||||
context: "focus",
|
||||
group: "Focus & Reply",
|
||||
},
|
||||
|
||||
// -- The app ------------------------------------------------------------------------------
|
||||
// The palette is the one thing a text field may not swallow: it is how you get out of anywhere.
|
||||
// The editor shadows it with its own Cmd+K, which is the exception that proves it.
|
||||
{
|
||||
keys: ["cmd+k"],
|
||||
command: "command-palette",
|
||||
label: "Command palette",
|
||||
context: "global",
|
||||
group: "App",
|
||||
allowInInput: true,
|
||||
},
|
||||
{ keys: ["?", "shift+?", "cmd+/"], command: "shortcuts", label: "Keyboard shortcuts", context: "view", group: "App", palette: true },
|
||||
// Escape unwinds the layer stack in `src/escape.ts`, which knows about nested confirmations.
|
||||
{
|
||||
keys: ["Escape"],
|
||||
command: null,
|
||||
label: "Dismiss whatever is open",
|
||||
context: "global",
|
||||
group: "App",
|
||||
},
|
||||
{ keys: ["cmd+r"], command: "sync-now", label: "Sync now", context: "view", group: "App", palette: true },
|
||||
{ keys: ["cmd+,"], command: "settings", label: "Settings", context: "view", group: "App", palette: true },
|
||||
// No key: renaming a thread is a thing you do to one thread once, and every letter worth a key
|
||||
// is already spoken for. The subject in the pane is the control; this row is how somebody finds
|
||||
// out that it is one.
|
||||
{
|
||||
keys: [],
|
||||
command: "rename",
|
||||
label: "Rename this thread",
|
||||
context: "view",
|
||||
group: "Triage",
|
||||
palette: true,
|
||||
},
|
||||
// A link on the New for you heading and a palette row, with no key of its own: it is the sort of
|
||||
// thing you do once a month, and a key for it would be a key you press by accident.
|
||||
{
|
||||
keys: [],
|
||||
command: "mark-all-seen",
|
||||
label: "Mark all as seen",
|
||||
context: "view",
|
||||
group: "Triage",
|
||||
palette: true,
|
||||
},
|
||||
// No key of its own. The theme is a setting rather than a verb, so it lives in the palette until
|
||||
// the Appearance section exists, and a binding with no keys is how the palette lists a command
|
||||
// the keyboard does not reach.
|
||||
{
|
||||
keys: [],
|
||||
command: "toggle-theme",
|
||||
label: "Toggle dark mode",
|
||||
context: "view",
|
||||
group: "App",
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: ["ctrl+0"],
|
||||
command: "accounts",
|
||||
label: "All accounts",
|
||||
context: "view",
|
||||
group: "App",
|
||||
palette: true,
|
||||
},
|
||||
// The two help rows. Neither has a key, because the corner button and the palette are both one
|
||||
// gesture already and a letter spent on the thing you need twice is a letter taken from a verb
|
||||
// you need hourly. The shortcut sheet above keeps `?`, which is the one help key anybody guesses.
|
||||
{
|
||||
keys: [],
|
||||
command: "tour",
|
||||
label: "Take the tour",
|
||||
context: "view",
|
||||
group: "App",
|
||||
palette: true,
|
||||
},
|
||||
{
|
||||
keys: [],
|
||||
command: "guide",
|
||||
label: "Guide",
|
||||
context: "view",
|
||||
group: "App",
|
||||
palette: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `Ctrl+1` to `Ctrl+9` switch account, which is nine bindings that are one idea. They are generated
|
||||
* rather than typed out, because nine near-identical rows in the sheet is nine chances to get one
|
||||
* wrong and no chance at all of noticing.
|
||||
*/
|
||||
export const ACCOUNT_KEYS: readonly string[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"].map(
|
||||
(n) => `ctrl+${n}`,
|
||||
);
|
||||
|
||||
export const GROUPS: readonly BindingGroup[] = [
|
||||
"Navigation",
|
||||
"Places",
|
||||
"Triage",
|
||||
"Reading",
|
||||
"Screener",
|
||||
"Writing",
|
||||
"Focus & Reply",
|
||||
"App",
|
||||
];
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
|
||||
|
||||
/** The primary modifier as the platform names it. */
|
||||
export const PRIMARY_LABEL = isMac ? "⌘" : "Ctrl+";
|
||||
|
||||
/** True when the event holds the platform's primary modifier, whatever the hardware calls it. */
|
||||
export const primaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
|
||||
isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
export const secondaryHeld = (e: { metaKey: boolean; ctrlKey: boolean }): boolean =>
|
||||
isMac ? e.ctrlKey : e.metaKey;
|
||||
|
||||
/**
|
||||
* The canonical form of a combo, which is what the dispatcher builds and the table is read into.
|
||||
*
|
||||
* Modifiers in `cmd+ctrl+alt+shift` order, then the key: lowercased when it is a single character,
|
||||
* verbatim when it is a named key like `Enter`. Shift is always an explicit modifier rather than
|
||||
* being baked into the letter, because this app binds both `Cmd+A` and `Cmd+Shift+A` and a table
|
||||
* that folded shift into the key could not tell them apart.
|
||||
*
|
||||
* A bare capital in the table is an affordance for writing `H` rather than `shift+h`, and it means
|
||||
* the same thing.
|
||||
*/
|
||||
export function normalizeCombo(combo: string): string {
|
||||
// A combo whose key is `+` splits into a trailing empty part; a combo whose key is a space keeps
|
||||
// it, which is why this does not trim.
|
||||
const parts = combo.split("+");
|
||||
let key = parts.pop() ?? "";
|
||||
if (key === "" && parts.length > 0) key = "+";
|
||||
const mods = new Set(parts.filter((p) => p !== "").map((p) => p.toLowerCase()));
|
||||
|
||||
// A bare capital means shift, because `H` is how a person writes the shifted `h`. A capital
|
||||
// behind a modifier does not, because `Cmd+K` is how a person writes Cmd and K, and reading that
|
||||
// as Cmd+Shift+K would break every combo in the table that is written the ordinary way.
|
||||
if (key.length === 1 && /[a-z]/i.test(key)) {
|
||||
if (mods.size === 0 && key !== key.toLowerCase()) mods.add("shift");
|
||||
key = key.toLowerCase();
|
||||
} else if (key.length === 1) {
|
||||
key = key.toLowerCase();
|
||||
}
|
||||
|
||||
const prefix = ["cmd", "ctrl", "alt", "shift"].filter((m) => mods.has(m)).join("+");
|
||||
return prefix ? `${prefix}+${key}` : key;
|
||||
}
|
||||
|
||||
const NAMED: Record<string, string> = {
|
||||
Enter: "↩",
|
||||
Escape: "⎋",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
Tab: "⇥",
|
||||
" ": "Space",
|
||||
};
|
||||
|
||||
/** `cmd+k` becomes ⌘K and `shift+h` becomes ⇧H. What the sheet, the palette and buttons print. */
|
||||
export function keyLabel(combo: string): string {
|
||||
const parts = normalizeCombo(combo).split("+");
|
||||
let key = parts.pop() ?? "";
|
||||
if (key === "" && parts.length > 0) key = "+";
|
||||
const printed = parts
|
||||
.map((m) => (m === "cmd" ? PRIMARY_LABEL : m === "ctrl" ? "⌃" : m === "shift" ? "⇧" : "⌥"))
|
||||
.join("");
|
||||
const named = NAMED[key];
|
||||
if (named) return `${printed}${named}`;
|
||||
return `${printed}${key.length === 1 ? key.toUpperCase() : key}`;
|
||||
}
|
||||
|
||||
/** The combos a command answers to, for a palette row or a button's title attribute. */
|
||||
export function keysFor(id: CommandId): readonly string[] {
|
||||
return BINDINGS.find((b) => b.command === id)?.keys ?? [];
|
||||
}
|
||||
|
||||
/** The first combo a command answers to, which is the one a button prints. */
|
||||
export function keyFor(id: CommandId): string | null {
|
||||
const keys = keysFor(id);
|
||||
return keys.length > 0 ? keys[0] : null;
|
||||
}
|
||||
|
||||
export function labelFor(id: CommandId): string {
|
||||
return BINDINGS.find((b) => b.command === id)?.label ?? id;
|
||||
}
|
||||
|
||||
/** Every command the palette lists, in declaration order. */
|
||||
export const PALETTE_COMMANDS: readonly CommandBinding[] = BINDINGS.filter(
|
||||
(b): b is CommandBinding => b.command !== null && b.palette === true,
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { commandMatches, isRegistered, registerCommands, runCommand } from "./commands";
|
||||
|
||||
describe("the command registry", () => {
|
||||
it("does nothing when nobody owns a command", () => {
|
||||
expect(() => runCommand("archive")).not.toThrow();
|
||||
expect(isRegistered("archive")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the handler that is mounted", () => {
|
||||
let ran = 0;
|
||||
const stop = registerCommands({ archive: () => (ran += 1) });
|
||||
runCommand("archive");
|
||||
expect(ran).toBe(1);
|
||||
stop();
|
||||
runCommand("archive");
|
||||
expect(ran).toBe(1);
|
||||
});
|
||||
|
||||
it("lets a screen take a verb over and hand it back", () => {
|
||||
const order: string[] = [];
|
||||
const stopList = registerCommands({ reply: () => order.push("list") });
|
||||
const stopFocus = registerCommands({ reply: () => order.push("focus") });
|
||||
runCommand("reply");
|
||||
stopFocus();
|
||||
runCommand("reply");
|
||||
stopList();
|
||||
expect(order).toEqual(["focus", "list"]);
|
||||
});
|
||||
|
||||
it("matches a label the way a palette does", () => {
|
||||
expect(commandMatches("Paper Trail", "pt")).toBe(true);
|
||||
expect(commandMatches("Archive", "arc")).toBe(true);
|
||||
expect(commandMatches("Archive", "")).toBe(true);
|
||||
expect(commandMatches("Archive", "zz")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// The command registry.
|
||||
//
|
||||
// A key, a menu item, a palette row and a button all end up here, which is the point: the native
|
||||
// menu emits an id and that id is a command, not a second code path.
|
||||
//
|
||||
// The calendar's version of this file holds the dispatch table itself and therefore imports every
|
||||
// store in the app. This one is a registry instead: `bindings.ts` declares what commands exist and
|
||||
// what they are called, and whichever screen owns a verb registers the function that performs it
|
||||
// while it is mounted. That keeps this whole directory free of the stores, which is what lets the
|
||||
// binding table, the shortcut sheet and the palette be tested without starting the app, and it
|
||||
// means a command whose screen is not on screen is simply not registered rather than being a
|
||||
// function that has to check.
|
||||
|
||||
import type { CommandId } from "./bindings";
|
||||
|
||||
export type Handler = () => void;
|
||||
|
||||
const handlers = new Map<CommandId, Handler[]>();
|
||||
|
||||
/**
|
||||
* Registers handlers for as long as the caller is mounted, and returns the function that takes
|
||||
* them away again. The most recently registered handler for a command wins, so a screen that
|
||||
* takes over a verb while it is open puts it back on the way out.
|
||||
*/
|
||||
export function registerCommands(map: Partial<Record<CommandId, Handler>>): () => void {
|
||||
const added: [CommandId, Handler][] = [];
|
||||
for (const [id, handler] of Object.entries(map) as [CommandId, Handler | undefined][]) {
|
||||
if (!handler) continue;
|
||||
const stack = handlers.get(id) ?? [];
|
||||
stack.push(handler);
|
||||
handlers.set(id, stack);
|
||||
added.push([id, handler]);
|
||||
}
|
||||
return () => {
|
||||
for (const [id, handler] of added) {
|
||||
const stack = handlers.get(id);
|
||||
if (!stack) continue;
|
||||
const at = stack.lastIndexOf(handler);
|
||||
if (at !== -1) stack.splice(at, 1);
|
||||
if (stack.length === 0) handlers.delete(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** True when something is listening, which is how the palette dims a row it cannot run. */
|
||||
export function isRegistered(id: CommandId): boolean {
|
||||
return (handlers.get(id)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command, or does nothing at all when nobody owns it.
|
||||
*
|
||||
* Doing nothing is deliberate and it is the rule docs/keyboard.md sets: a key that would act on
|
||||
* nothing does nothing and shows nothing. Pressing `l` with no thread selected is not an error and
|
||||
* must not produce a toast saying so.
|
||||
*/
|
||||
export function runCommand(id: CommandId): void {
|
||||
const stack = handlers.get(id);
|
||||
if (!stack || stack.length === 0) return;
|
||||
stack[stack.length - 1]();
|
||||
}
|
||||
|
||||
/** Case-insensitive subsequence, so `pt` finds "Paper Trail" and `arc` finds "Archive". */
|
||||
export function commandMatches(label: string, query: string): boolean {
|
||||
const needle = query.toLowerCase().replace(/\s+/g, "");
|
||||
if (!needle) return true;
|
||||
const hay = label.toLowerCase();
|
||||
let at = 0;
|
||||
for (const ch of needle) {
|
||||
at = hay.indexOf(ch, at);
|
||||
if (at === -1) return false;
|
||||
at += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// One capture-phase listener for the whole app, and a context stack that decides what it is
|
||||
// allowed to do.
|
||||
//
|
||||
// The stack starts empty, which means the view's keymap. A card, an editor or an overlay pushes a
|
||||
// frame and the whole `view` context is shadowed until it pops, so whatever is in front owns the
|
||||
// keyboard without any component having to remember to unbind anything. That is what lets `y` mean
|
||||
// note in the list, yes on a Screener card and accept on an invite without any of the three
|
||||
// knowing the others exist.
|
||||
//
|
||||
// Escape is not part of this: `src/escape.ts` already stacks Escape handlers and knows about nested
|
||||
// confirmations, so this listener steps over the key entirely rather than racing it.
|
||||
//
|
||||
// A key is never taken from a text field unless the binding says so, and the two that say so are
|
||||
// the palette, which is how you get out of anywhere, and the editor's own keys.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { runCommand } from "./commands";
|
||||
import {
|
||||
ACCOUNT_KEYS,
|
||||
BINDINGS,
|
||||
normalizeCombo,
|
||||
primaryHeld,
|
||||
secondaryHeld,
|
||||
type Binding,
|
||||
type KeyContext,
|
||||
} from "./bindings";
|
||||
|
||||
const index = new Map<string, Binding[]>();
|
||||
for (const binding of BINDINGS) {
|
||||
for (const key of binding.keys) {
|
||||
const combo = normalizeCombo(key);
|
||||
const found = index.get(combo);
|
||||
if (found) found.push(binding);
|
||||
else index.set(combo, [binding]);
|
||||
}
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
context: KeyContext;
|
||||
}
|
||||
|
||||
const stack: Frame[] = [];
|
||||
|
||||
const activeContext = (): KeyContext => stack[stack.length - 1]?.context ?? "view";
|
||||
|
||||
/** Takes the keyboard until the returned function is called. Frames are identity, never by name. */
|
||||
export function pushContext(context: KeyContext): () => void {
|
||||
const frame: Frame = { context };
|
||||
stack.push(frame);
|
||||
return () => {
|
||||
const at = stack.indexOf(frame);
|
||||
if (at !== -1) stack.splice(at, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** The hook form, for a component that owns the keyboard while it is on screen. */
|
||||
export function useKeyContext(context: KeyContext, active = true): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return pushContext(context);
|
||||
}, [context, active]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The combo an event means, in the same canonical form the table is read into.
|
||||
*
|
||||
* Shift is kept as a modifier rather than folded into the letter, so `Cmd+A` and `Cmd+Shift+A` are
|
||||
* different combos. A punctuation key that only exists shifted arrives with shift held and its own
|
||||
* character, so both spellings are in the table for those.
|
||||
*/
|
||||
export function comboOf(e: KeyboardEvent): string {
|
||||
const mods =
|
||||
(primaryHeld(e) ? "cmd+" : "") +
|
||||
(secondaryHeld(e) ? "ctrl+" : "") +
|
||||
(e.altKey ? "alt+" : "") +
|
||||
(e.shiftKey ? "shift+" : "");
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
return `${mods}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which binding a combo means right now.
|
||||
*
|
||||
* The frames layer rather than replace. A Screener card declares `y`, `v`, `n` and `r`, and while
|
||||
* it is up those four mean what the card says; every other key still means what the view says, so
|
||||
* `j`, `k`, `Enter` and the place keys keep working on a screen that needs all of them. Only
|
||||
* `overlay` and `editor` shadow the view wholesale, because a panel and a text field really do own
|
||||
* the keyboard while they are in front.
|
||||
*
|
||||
* An earlier version of this function fell back from the top frame straight to `global`, which
|
||||
* meant pushing a card frame killed the list's keys. That is the bug this comment exists to stop
|
||||
* somebody reintroducing while tidying.
|
||||
*/
|
||||
const SHADOWS_VIEW: readonly KeyContext[] = ["overlay", "editor"];
|
||||
|
||||
function resolve(combo: string): Binding | null {
|
||||
const candidates = index.get(combo);
|
||||
if (!candidates) return null;
|
||||
const top = activeContext();
|
||||
|
||||
const inTop = candidates.find((b) => b.context === top);
|
||||
if (inTop) return inTop;
|
||||
|
||||
if (!SHADOWS_VIEW.includes(top)) {
|
||||
const inView = candidates.find((b) => b.context === "view");
|
||||
if (inView) return inView;
|
||||
}
|
||||
return candidates.find((b) => b.context === "global") ?? null;
|
||||
}
|
||||
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string") return false;
|
||||
if (el.isContentEditable) return true;
|
||||
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
||||
}
|
||||
|
||||
/**
|
||||
* A button the user can tab to activates itself on Enter, so the keymap leaves that alone. A list
|
||||
* row is `tabIndex={-1}` and only ever focused by a click, and it is the keymap's job to open it,
|
||||
* so the test is the tab index rather than the tag.
|
||||
*/
|
||||
function isActivatable(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string" || el.tabIndex < 0) return false;
|
||||
return (
|
||||
el.tagName === "BUTTON" ||
|
||||
el.tagName === "A" ||
|
||||
el.tagName === "SUMMARY" ||
|
||||
el.getAttribute("role") === "button"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching account is nine keys and one idea, so it is not a command with nine bindings. The shell
|
||||
* hands over the one function that does it.
|
||||
*/
|
||||
let switchAccount: (index: number) => void = () => {};
|
||||
|
||||
export function setAccountSwitch(fn: (index: number) => void): () => void {
|
||||
switchAccount = fn;
|
||||
return () => {
|
||||
switchAccount = () => {};
|
||||
};
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.isComposing || e.defaultPrevented) return;
|
||||
if (e.key === "Escape") return;
|
||||
if ((e.key === "Enter" || e.key === " ") && isActivatable(e.target)) return;
|
||||
|
||||
const combo = comboOf(e);
|
||||
|
||||
// The nine account keys, which are one idea rather than nine bindings.
|
||||
const account = ACCOUNT_KEYS.indexOf(combo);
|
||||
if (account !== -1 && activeContext() !== "overlay") {
|
||||
e.preventDefault();
|
||||
switchAccount(account);
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = resolve(combo);
|
||||
if (!binding) return;
|
||||
|
||||
if (!binding.allowInInput && (isTyping(e.target) || isTyping(document.activeElement))) return;
|
||||
|
||||
// A documented key with no command of ours: the editor's own, and Escape. Consume nothing.
|
||||
if (binding.command === null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
runCommand(binding.command);
|
||||
}
|
||||
|
||||
let installs = 0;
|
||||
|
||||
/** Installs the one listener. Reference counted, so React's double effect in development is fine. */
|
||||
export function installKeymap(): () => void {
|
||||
installs += 1;
|
||||
if (installs === 1) window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
installs -= 1;
|
||||
if (installs === 0) window.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}
|
||||
|
||||
/** Mount once, at the top of the tree. */
|
||||
export function useKeymap(): void {
|
||||
useEffect(() => installKeymap(), []);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The native menu emits `menu-action` with the item id it was built with. Those ids are command
|
||||
// ids, so this is a guard and a lookup rather than a second dispatch table: a menu item and a
|
||||
// keystroke run the same function or the build fails.
|
||||
//
|
||||
// The Tauri listener itself is mounted at the top of the tree; this is what it calls.
|
||||
|
||||
import type { CommandId } from "./bindings";
|
||||
import { runCommand } from "./commands";
|
||||
|
||||
/** Exactly the ids `src-tauri/src/lib.rs` emits, and every one of them is a command. */
|
||||
const MENU_IDS: readonly CommandId[] = [
|
||||
"compose",
|
||||
"command-palette",
|
||||
"sync-now",
|
||||
"check-updates",
|
||||
"settings",
|
||||
"search",
|
||||
"shortcuts",
|
||||
"place-inbox",
|
||||
"place-feed",
|
||||
"place-paper-trail",
|
||||
"toggle-pane",
|
||||
"guide",
|
||||
"tour",
|
||||
"report-issue",
|
||||
];
|
||||
|
||||
const known = new Set<string>(MENU_IDS);
|
||||
|
||||
export function handleMenuAction(id: string): void {
|
||||
if (known.has(id)) runCommand(id as CommandId);
|
||||
}
|
||||
|
||||
/** The ids this module answers to, so a test can check them against the Rust side's list. */
|
||||
export const MENU_COMMAND_IDS = MENU_IDS;
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Layout only. Every value is a token, and the sheet itself is the primitive's. */
|
||||
|
||||
.shortcuts {
|
||||
column-count: 2;
|
||||
column-gap: 32px;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.shortcuts-heading {
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 12px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.shortcuts-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.shortcuts-keys {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.shortcuts-label {
|
||||
margin: 0;
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.shortcuts-note {
|
||||
margin: 16px 0 0;
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Where a place was when you left it, which today means the Feed and nothing else.
|
||||
//
|
||||
// This belongs in the portable state database, in the `markers` table behind
|
||||
// `state::write::set_marker`: a marker is a per place `seen_ms` that roams with the account through
|
||||
// the backup store, so the hairline sits in the same spot on the laptop and on the phone. There is
|
||||
// no command for it in the frozen contract, and the rule is that bodies get added to Rust rather
|
||||
// than wrappers to `src/api/`, so it waits here until `markers` is reachable over IPC. Nothing else
|
||||
// about this file changes when it moves: one number in, one number out.
|
||||
//
|
||||
// Out here rather than inside `useFeed` because a store may not touch disk, which is the same
|
||||
// reason `src/pane.ts` and `src/theme.ts` are modules of their own.
|
||||
|
||||
const key = (place: string) => `marginmail-left-off-${place}`;
|
||||
|
||||
/** The instant the newest card on screen carried when the place was last left. */
|
||||
export function readLeftOff(place: string): number | null {
|
||||
try {
|
||||
const held = Number(localStorage.getItem(key(place)));
|
||||
return Number.isFinite(held) && held > 0 ? held : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLeftOff(place: string, ms: number): void {
|
||||
try {
|
||||
localStorage.setItem(key(place), String(ms));
|
||||
} catch {
|
||||
/* a context without storage is a context that forgets, which is the worst of it */
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,11 +1,29 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { isMacDesktop } from "./ipc";
|
||||
|
||||
// The three layers, in the order the design system stacks them: the tokens, the faces, then the
|
||||
// base sheet the primitives and the screens layer on top of.
|
||||
//
|
||||
// Before the app, and that is the whole point of the order. A stylesheet imported by a component
|
||||
// lands in the bundle where its module was first evaluated, so importing App first put every
|
||||
// primitive's sheet above app.css and let `.panel` win ties against `.palette`.
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/fonts.css";
|
||||
import "./styles/app.css";
|
||||
|
||||
import App from "./App";
|
||||
import { logNote } from "./api/log";
|
||||
|
||||
// Whatever the webview would otherwise say only to a developer console nobody has open. Same
|
||||
// file as the engine's failures, so the log reads as one account of what went wrong.
|
||||
window.addEventListener("error", (event) => {
|
||||
void logNote("ui", `uncaught: ${event.message} (${event.filename}:${event.lineno})`);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
void logNote("ui", `unhandled: ${String(event.reason)}`);
|
||||
});
|
||||
|
||||
// The header's lane for the traffic lights, which only one platform draws over it. Written here
|
||||
// rather than assumed by the stylesheet, so the first paint is already the right shape.
|
||||
if (isMacDesktop) document.documentElement.setAttribute("data-traffic", "");
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// The reading pane's visibility, on `data-no-pane` on the root and in localStorage, exactly the
|
||||
// shape `src/theme.ts` uses for the theme and for the same reason: the boot script in index.html
|
||||
// has already applied the stored value before React mounts, so the DOM is the answer and storage
|
||||
// is only the fallback.
|
||||
//
|
||||
// It lives out here rather than inside `useMail` because the store may not touch the DOM, and it
|
||||
// has to be an attribute rather than a class on a component: whether there is a second column is a
|
||||
// layout fact the stylesheet needs on the first paint, not after a render.
|
||||
|
||||
const KEY = "marginmail-pane";
|
||||
|
||||
export function initialPane(): boolean {
|
||||
if (document.documentElement.hasAttribute("data-no-pane")) return false;
|
||||
return localStorage.getItem(KEY) !== "0";
|
||||
}
|
||||
|
||||
export function applyPane(open: boolean): void {
|
||||
document.documentElement.toggleAttribute("data-no-pane", !open);
|
||||
localStorage.setItem(KEY, open ? "1" : "0");
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Where an address goes, decided twice: from the domain, and from the servers discovery found.
|
||||
//
|
||||
// These are the decisions with a right answer. A gmail.com address that ended up on a password
|
||||
// panel would be a screen that fails on purpose, and a work domain hosted by Google that was not
|
||||
// recognised would be the same failure one lookup later.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MailConfig } from "./ipc";
|
||||
import { closedName, domainOf, passwordHint, providerName, routeFor, routeOf } from "./providers";
|
||||
|
||||
function found(imapHost: string, displayName: string | null = null): MailConfig {
|
||||
const leg = (host: string) => ({
|
||||
host,
|
||||
port: 993,
|
||||
security: "tls" as const,
|
||||
auth: "password" as const,
|
||||
username: "[email protected]",
|
||||
});
|
||||
return { imap: leg(imapHost), smtp: leg(imapHost), source: "ispdb", displayName };
|
||||
}
|
||||
|
||||
describe("routeFor", () => {
|
||||
it("sends Google's own domains to the browser without a lookup", () => {
|
||||
expect(routeFor("[email protected]")).toBe("google");
|
||||
expect(routeFor("[email protected]")).toBe("google");
|
||||
});
|
||||
|
||||
it("recognises Microsoft's consumer domains under any country's ending", () => {
|
||||
for (const address of [
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
]) {
|
||||
expect(routeFor(address), address).toBe("microsoft");
|
||||
}
|
||||
});
|
||||
|
||||
it("knows the providers that have no IMAP at all, by name", () => {
|
||||
expect(routeFor("[email protected]")).toBe("closed");
|
||||
expect(routeFor("[email protected]")).toBe("closed");
|
||||
expect(closedName("[email protected]")).toBe("HEY");
|
||||
expect(closedName("[email protected]")).toBe("Tuta");
|
||||
});
|
||||
|
||||
it("looks everything else up, including domains that merely contain a provider's name", () => {
|
||||
expect(routeFor("[email protected]")).toBe("lookup");
|
||||
expect(routeFor("[email protected]")).toBe("lookup");
|
||||
expect(routeFor("[email protected]")).toBe("lookup");
|
||||
expect(routeFor("[email protected]")).toBe("lookup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeOf", () => {
|
||||
it("knows a custom domain by the servers it was found on", () => {
|
||||
expect(routeOf(found("imap.gmail.com"))).toBe("google");
|
||||
expect(routeOf(found("imap.googlemail.com"))).toBe("google");
|
||||
expect(routeOf(found("outlook.office365.com"))).toBe("microsoft");
|
||||
expect(routeOf(found("imap-mail.outlook.com"))).toBe("microsoft");
|
||||
});
|
||||
|
||||
it("leaves everyone else on the password", () => {
|
||||
expect(routeOf(found("imap.fastmail.com"))).toBe("password");
|
||||
expect(routeOf(found("127.0.0.1"))).toBe("password");
|
||||
expect(routeOf(found("mail.gmail.example"))).toBe("password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerName", () => {
|
||||
it("is what the provider calls itself, or the domain when it said nothing", () => {
|
||||
expect(providerName(found("imap.fastmail.com", "Fastmail"), "[email protected]")).toBe(
|
||||
"Fastmail",
|
||||
);
|
||||
expect(providerName(found("mail.northgate.example"), "[email protected]")).toBe(
|
||||
"northgate.example",
|
||||
);
|
||||
expect(providerName(null, "nonsense")).toBe("this address");
|
||||
});
|
||||
});
|
||||
|
||||
describe("passwordHint", () => {
|
||||
it("names the kind of password a provider wants before it is typed", () => {
|
||||
expect(passwordHint(found("imap.fastmail.com"))).toContain("app password");
|
||||
expect(passwordHint(found("imap.mail.me.com"))).toContain("app-specific password");
|
||||
expect(passwordHint(found("imap.mail.yahoo.com"))).toContain("Yahoo");
|
||||
expect(passwordHint(found("imap.aol.com"))).toContain("AOL");
|
||||
});
|
||||
|
||||
it("says a loopback is a bridge with a password of its own", () => {
|
||||
expect(passwordHint(found("127.0.0.1"))).toContain("Bridge shows under Mailbox details");
|
||||
});
|
||||
|
||||
it("has nothing to add for a server it knows nothing about", () => {
|
||||
expect(passwordHint(found("mail.northgate.example"))).toBeNull();
|
||||
expect(passwordHint(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("domainOf", () => {
|
||||
it("is the lowercased half after the last @", () => {
|
||||
expect(domainOf("[email protected] ")).toBe("example.com");
|
||||
expect(domainOf("nonsense")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// Which way in an address takes, and what the provider behind it is going to want.
|
||||
//
|
||||
// The connect screen asks for one thing, the address, and everything after it is worked out. Two
|
||||
// providers cannot be reached with a password at all: Google, which this app talks to through its
|
||||
// API after a sign-in in the browser, and Microsoft, which closed password sign-in and wants an app
|
||||
// registration Margin does not have. Both are recognised twice. From the domain, when it is one of
|
||||
// theirs, so a gmail.com address never waits on a lookup. And from the servers discovery hands
|
||||
// back, when it is a custom domain they host, which is what a mailbox at work usually turns out
|
||||
// to be.
|
||||
//
|
||||
// The hints are the other half. The single most common way an IMAP setup fails is a person typing
|
||||
// the password they sign in to the website with into a provider that wants a password made for
|
||||
// the purpose, and every one of those providers refuses in its own vocabulary. Saying which kind
|
||||
// of password to type, before it is typed, is worth more than any sentence after the refusal.
|
||||
|
||||
import type { MailConfig } from "./ipc";
|
||||
|
||||
export type Route = "google" | "microsoft" | "closed" | "password";
|
||||
|
||||
/** The domain half of an address, lowercased. Empty when there is no @ to split on. */
|
||||
export const domainOf = (email: string): string => {
|
||||
const at = email.lastIndexOf("@");
|
||||
return at < 0 ? "" : email.slice(at + 1).trim().toLowerCase();
|
||||
};
|
||||
|
||||
const GOOGLE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
||||
|
||||
/** outlook.com, hotmail.com and live.com under any country's ending (hotmail.co.uk, live.com.au,
|
||||
* outlook.de), and msn.com. The middle part is a country's own second level and nothing longer,
|
||||
* so a domain that merely starts with one of the names is not one of theirs. */
|
||||
const MICROSOFT_DOMAIN = /^(?:outlook|hotmail|live)\.(?:(?:co|com|ne)\.)?[a-z]{2,3}$|^msn\.com$/;
|
||||
|
||||
/**
|
||||
* The providers with no IMAP, no POP and no other way in for a mail client, by their own account.
|
||||
* HEY says so in its help pages and Tuta in its FAQ. Discovery would climb every rung for these and
|
||||
* land on the servers sheet, which is the one screen that cannot help.
|
||||
*/
|
||||
const CLOSED: Record<string, string> = {
|
||||
"hey.com": "HEY",
|
||||
"tuta.com": "Tuta",
|
||||
"tutanota.com": "Tuta",
|
||||
"tutamail.com": "Tuta",
|
||||
"tuta.io": "Tuta",
|
||||
"keemail.me": "Tuta",
|
||||
};
|
||||
|
||||
/** From the address alone: the providers whose consumer domains say everything already. */
|
||||
export function routeFor(email: string): Route | "lookup" {
|
||||
const domain = domainOf(email);
|
||||
if (GOOGLE_DOMAINS.has(domain)) return "google";
|
||||
if (MICROSOFT_DOMAIN.test(domain)) return "microsoft";
|
||||
if (domain in CLOSED) return "closed";
|
||||
return "lookup";
|
||||
}
|
||||
|
||||
/** The name of a provider that keeps its mail to its own app, for the sentence that says so. */
|
||||
export const closedName = (email: string): string => CLOSED[domainOf(email)] ?? domainOf(email);
|
||||
|
||||
const GOOGLE_HOST = /(?:^|\.)(?:gmail|googlemail|google)\.com$/;
|
||||
const MICROSOFT_HOST = /(?:^|\.)(?:office365|outlook|hotmail|live)\.com$/;
|
||||
|
||||
/** From what discovery found: a custom domain is whoever its incoming server belongs to. */
|
||||
export function routeOf(config: MailConfig): Route {
|
||||
const host = config.imap.host.trim().toLowerCase();
|
||||
if (GOOGLE_HOST.test(host)) return "google";
|
||||
if (MICROSOFT_HOST.test(host)) return "microsoft";
|
||||
return "password";
|
||||
}
|
||||
|
||||
const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[?::1\]?)$/i;
|
||||
|
||||
/** What the provider calls itself, or the domain when it did not say. */
|
||||
export function providerName(config: MailConfig | null, email: string): string {
|
||||
const said = config?.displayName?.trim();
|
||||
return said && said.length > 0 ? said : domainOf(email) || "this address";
|
||||
}
|
||||
|
||||
interface Hint {
|
||||
/** Matched against the incoming host, lowercased. */
|
||||
host: RegExp;
|
||||
/** What to type, and where it comes from, in one or two sentences. */
|
||||
says: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The providers known to want a password made for the purpose, keyed on the server they publish.
|
||||
*
|
||||
* The patterns match the provider's own domain under any ending rather than one exact host, so a
|
||||
* regional host or a renamed one still gets its sentence. The sentences name the place in the
|
||||
* provider's settings where the password is made, in the provider's own words, because "an app
|
||||
* password" on its own sends a person searching.
|
||||
*/
|
||||
const HINTS: Hint[] = [
|
||||
{
|
||||
host: /(?:^|\.)fastmail\.[a-z.]+$/,
|
||||
says:
|
||||
"Fastmail only accepts an app password here, never the one you sign in with. Make one in " +
|
||||
"Fastmail's settings under Privacy & Security, then Connected apps & API tokens.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)(?:mail\.me|icloud)\.com$/,
|
||||
says:
|
||||
"iCloud only accepts an app-specific password here, never your Apple Account password, and " +
|
||||
"needs two-factor authentication turned on. Make one at account.apple.com under Sign-In " +
|
||||
"and Security.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)yahoo\.[a-z.]+$/,
|
||||
says:
|
||||
"Yahoo only accepts an app password here, never the one you sign in with. Make one on the " +
|
||||
"Account Security page of your Yahoo account, under Generate app password.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)aol\.com$/,
|
||||
says:
|
||||
"AOL only accepts an app password here, never the one you sign in with. Make one on the " +
|
||||
"Account Security page of your AOL account, under Generate app password.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)zoho(?:mail)?\.[a-z.]+$/,
|
||||
says:
|
||||
"With multi-factor sign-in on, Zoho wants an app-specific password rather than the one you " +
|
||||
"sign in with, made under Security in Zoho Accounts. IMAP also has to be turned on under " +
|
||||
"Settings, then Mail Accounts.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)(?:gmx\.[a-z.]+|mail\.com)$/,
|
||||
says:
|
||||
"GMX and mail.com keep IMAP switched off until you turn it on under Settings, then POP3 & " +
|
||||
"IMAP, in the web mail. The password is the one you sign in with, unless two-factor is on, " +
|
||||
"when it is an app-specific password from Security Options.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)(?:yandex\.[a-z.]+|ya\.ru)$/,
|
||||
says:
|
||||
"Yandex wants an app password rather than the one you sign in with, made in Yandex ID under " +
|
||||
"Security, then App passwords. IMAP has to be turned on under Settings, then Email clients.",
|
||||
},
|
||||
{
|
||||
host: /(?:^|\.)mailbox\.org$/,
|
||||
says:
|
||||
"mailbox.org takes the password you sign in with, unless two-factor is on, when it wants an " +
|
||||
"app password made under Security, then Email app-passwords.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The sentence under the password field, when there is something worth saying before it is typed.
|
||||
* A bridge on this machine is the one case that is about where the servers are rather than who
|
||||
* runs them: Proton's published settings point at Bridge, and Bridge has a password of its own.
|
||||
*/
|
||||
export function passwordHint(config: MailConfig | null): string | null {
|
||||
if (!config) return null;
|
||||
const host = config.imap.host.trim().toLowerCase();
|
||||
if (LOOPBACK.test(host)) {
|
||||
return (
|
||||
"These servers are Proton Bridge running on this machine, or another bridge like it. The " +
|
||||
"password is the one Bridge shows under Mailbox details, not your Proton password, and " +
|
||||
"Bridge has to be running."
|
||||
);
|
||||
}
|
||||
return HINTS.find((hint) => hint.host.test(host))?.says ?? null;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Button, Icon, icons, Key, Sheet } from "../ui";
|
||||
import { runCommand } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import type { CommandId } from "../keys/bindings";
|
||||
import type { ThreadSummary } from "../ipc";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useSelection } from "../store/useSelection";
|
||||
import { cap } from "./format";
|
||||
import * as triage from "./triage";
|
||||
import "./actionbar.css";
|
||||
|
||||
/**
|
||||
* The bar that takes the piles' place while a selection exists, carrying the same verbs as the
|
||||
* keyboard and printing the same keys, because this is where the mouse teaches the keyboard.
|
||||
*
|
||||
* Every button runs the command rather than the function behind it, so the button and the key are
|
||||
* one code path and both act on the selection the same way.
|
||||
*
|
||||
* `l`, `s`, `b` and `g` all act on a selection now and none of them has a button here yet, which
|
||||
* is a gap rather than a decision: docs/features.md section 13 lists them, and six verbs is what
|
||||
* fits in three across and two down inside the piles' height. Adding four more is a taller
|
||||
* footprint for both this and the piles, and that measurement is asserted in two places.
|
||||
*/
|
||||
interface Verb {
|
||||
command: CommandId;
|
||||
/**
|
||||
* A toggle says which way it is about to go, read off the rows it would act on, the same way the
|
||||
* More menu's rows do. Over a mixed selection that is the direction most of it has not gone yet.
|
||||
*/
|
||||
label: string | ((rows: ThreadSummary[]) => string);
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verbs, in the order docs/features.md section 13 lists them.
|
||||
*
|
||||
* Six of them, in the piles' footprint, which is two rows of three. The section lists ten, and the
|
||||
* width is what decides: the list column is 420 pixels and a button here is an icon, a word and a
|
||||
* keycap, which is four to a row before the last one is clipped. So the six that lead the section
|
||||
* get a button and the other four keep their keys, which work on a selection exactly the same way.
|
||||
* A bar that clipped its own labels would be worse than a bar that shows fewer of them.
|
||||
*
|
||||
* Every button runs the command rather than a function of its own, so a button and a keystroke are
|
||||
* one path and cannot come to mean different things.
|
||||
*/
|
||||
const VERBS: Verb[] = [
|
||||
{ command: "reply-later", label: "Reply later", icon: icons.CLOCK },
|
||||
{ command: "set-aside", label: "Set aside", icon: icons.SET_ASIDE },
|
||||
{ command: "snooze", label: "Snooze", icon: icons.SNOOZE },
|
||||
{ command: "toggle-seen", label: "Mark seen", icon: icons.ENVELOPE },
|
||||
{ command: "archive", label: "Archive", icon: icons.ARCHIVE },
|
||||
{
|
||||
command: "trash",
|
||||
label: (rows) => (rows.length > 0 && rows.every((t) => t.trashed) ? "Put back" : "Trash"),
|
||||
icon: icons.TRASH,
|
||||
},
|
||||
];
|
||||
|
||||
export function ActionBar() {
|
||||
const selected = useSelection((s) => s.keys);
|
||||
const clear = useSelection((s) => s.clear);
|
||||
const threads = useMail((s) => s.threads);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const wanted = new Set(selected);
|
||||
return threads.filter((thread) => wanted.has(thread.key));
|
||||
}, [threads, selected]);
|
||||
|
||||
return (
|
||||
<div className="action-bar">
|
||||
<div className="action-head">
|
||||
<span className="action-count">{`${selected.length} selected`}</span>
|
||||
<button type="button" className="action-clear" onClick={clear}>
|
||||
Clear
|
||||
<Key size="sm">⎋</Key>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="action-verbs">
|
||||
{VERBS.map((verb) => (
|
||||
<Button
|
||||
key={verb.command}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={verb.icon}
|
||||
keycap={cap(verb.command)}
|
||||
onClick={() => runCommand(verb.command)}
|
||||
>
|
||||
{typeof verb.label === "function" ? verb.label(rows) : verb.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type PickerMode = "apply" | "move";
|
||||
|
||||
interface PickerProps {
|
||||
mode: PickerMode | null;
|
||||
/** The threads the picked label lands on. */
|
||||
keys: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's labels, as a list to pick one from. `Shift+L` applies or removes; `v` in a label
|
||||
* list moves, which to a provider with labels means applying one and archiving.
|
||||
*
|
||||
* A row cannot say which labels it carries, so a tick is only shown when the thread it belongs to
|
||||
* is the one open in the pane, which is the only place the app knows. Picking an untold label
|
||||
* applies it, which is the direction somebody pressing `Shift+L` meant.
|
||||
*/
|
||||
export function LabelPicker({ mode, keys, onClose }: PickerProps) {
|
||||
const labels = useMail((s) => s.labels);
|
||||
const labelsPhase = useMail((s) => s.labelsPhase);
|
||||
const loadLabels = useMail((s) => s.loadLabels);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
const thread = useMail((s) => s.thread);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode) void loadLabels();
|
||||
}, [mode, accountId, loadLabels]);
|
||||
|
||||
// The sheet is in front, so the view's keys stand back until it closes.
|
||||
useKeyContext("overlay", mode !== null);
|
||||
|
||||
const applied = useMemo(() => {
|
||||
const one = keys.length === 1 && keys[0] === openKey;
|
||||
return new Set(one ? (thread?.labels ?? []) : []);
|
||||
}, [keys, openKey, thread]);
|
||||
|
||||
if (!mode) return null;
|
||||
|
||||
const choose = (id: string) => {
|
||||
onClose();
|
||||
if (mode === "move") void triage.move(keys, id);
|
||||
else void triage.label(keys, id, !applied.has(id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
size="mini"
|
||||
title={mode === "move" ? "Move to a label" : "Label"}
|
||||
onClose={onClose}
|
||||
>
|
||||
<ul className="label-list">
|
||||
{labels.map((label) => (
|
||||
<li key={label.id}>
|
||||
<button type="button" className="label-option" onClick={() => choose(label.id)}>
|
||||
<span className="label-name">{label.name}</span>
|
||||
{applied.has(label.id) ? <Icon d={icons.CHECK} size={14} /> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{/* An empty list means none only once a read has come back. While one is out, or after
|
||||
one failed, saying "no labels" would be answering a question nobody has asked yet. */}
|
||||
{labels.length === 0 ? (
|
||||
labelsPhase === "loading" ? (
|
||||
<li className="label-none" data-state="loading">
|
||||
Reading your labels
|
||||
</li>
|
||||
) : labelsPhase === "error" ? (
|
||||
<li className="label-none" data-state="error">
|
||||
<span>Could not read your labels.</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => void loadLabels()}>
|
||||
Try again
|
||||
</Button>
|
||||
</li>
|
||||
) : (
|
||||
<li className="label-none">No labels on this account</li>
|
||||
)
|
||||
) : null}
|
||||
</ul>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionBar;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Button, Sheet } from "../ui";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { arrival, useSync } from "../store/useSync";
|
||||
import { spanOf, WindowChoice } from "./WindowChoice";
|
||||
import "./arriving.css";
|
||||
|
||||
/**
|
||||
* The panel over the window while an account added from inside the app brings its mail in.
|
||||
*
|
||||
* The welcome screen has its own stage for this wait, because there is nothing behind it. Here
|
||||
* there is: Settings, or the Inbox of another account, and the account that has just been added
|
||||
* used to land under them as an empty Inbox with one line in the header saying why. This takes the
|
||||
* window instead. First the one question, how far back this device holds, because the first sync
|
||||
* reads the answer; then the engine's own sentence, a bar and the count, and nothing else to do:
|
||||
* the close control and Escape are off, and the scrim is not a way out. It ends when the engine
|
||||
* says the mail is in, and what is behind it then is that account's Inbox.
|
||||
*
|
||||
* A first pass that stopped is the one thing it has to offer a way out of. The provider's own
|
||||
* sentence, Try again, and Go in anyway: an account that is going to stay broken for a while is
|
||||
* still an account, and the Inbox says what it has.
|
||||
*/
|
||||
export function Arriving() {
|
||||
const arriving = useAccounts((s) => s.arriving);
|
||||
const arrived = useAccounts((s) => s.arrived);
|
||||
const seedScreener = useAccounts((s) => s.seedScreener);
|
||||
const starting = useAccounts((s) => s.starting);
|
||||
const startSync = useAccounts((s) => s.startSync);
|
||||
const statuses = useSync((s) => s.statuses);
|
||||
const retry = useSync((s) => s.run);
|
||||
|
||||
const status = arriving
|
||||
? (statuses.find((s) => s.accountId === arriving.accountId) ?? null)
|
||||
: null;
|
||||
// Nothing has started until the window is chosen, whatever an older status for the id says.
|
||||
const state = arriving?.started ? arrival(status) : "working";
|
||||
|
||||
const handOver = useCallback(() => {
|
||||
if (!arriving) return;
|
||||
const { accountId } = arriving;
|
||||
arrived();
|
||||
useSettings.getState().close();
|
||||
useMail.getState().goTo("inbox");
|
||||
useMail.getState().setAccount(accountId);
|
||||
// Everyone the account already knows is screened in by the pass that finished, and this is
|
||||
// what puts the number on the first-run panel. Asked after the hand-over, so a mirror that
|
||||
// is not ready yet (Go in anyway) is asked again when its sync reports idle.
|
||||
void seedScreener(accountId);
|
||||
}, [arriving, arrived, seedScreener]);
|
||||
|
||||
useEffect(() => {
|
||||
if (arriving?.started && state === "done") handOver();
|
||||
}, [arriving, state, handOver]);
|
||||
|
||||
if (!arriving) return null;
|
||||
|
||||
const total = status?.total ?? 0;
|
||||
const hydrated = Math.min(status?.hydrated ?? 0, total);
|
||||
const done = total > 0 ? hydrated / total : 0;
|
||||
const stalled = state === "stalled";
|
||||
const offline = status?.phase === "offline";
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
title={`Bringing in ${arriving.email}`}
|
||||
busy
|
||||
onClose={() => {}}
|
||||
foot={
|
||||
stalled ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handOver}>
|
||||
Go in anyway
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-autofocus
|
||||
onClick={() => void retry(arriving.accountId)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="arrive" data-state={arriving.started ? state : "choosing"}>
|
||||
{!arriving.started ? (
|
||||
<>
|
||||
<p className="arrive-line">How far back should this device hold?</p>
|
||||
<WindowChoice
|
||||
initial={arriving.days}
|
||||
busy={starting}
|
||||
onStart={(days) => void startSync(days)}
|
||||
/>
|
||||
</>
|
||||
) : stalled ? (
|
||||
<>
|
||||
<p className="arrive-line">
|
||||
{offline
|
||||
? "No connection. The account is connected and nothing was lost; the mail comes in as soon as there is a network."
|
||||
: "The account is connected and its sign-in is stored. The mailbox itself refused the first request."}
|
||||
</p>
|
||||
{status?.error ? <p className="arrive-trouble">{status.error}</p> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="arrive-line">{status?.message ?? "Listing your mail"}</p>
|
||||
<div
|
||||
className="arrive-bar"
|
||||
data-counting={total > 0 ? undefined : ""}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={total > 0 ? Math.round(done * 100) : undefined}
|
||||
>
|
||||
<span
|
||||
className="arrive-fill"
|
||||
style={total > 0 ? { transform: `scaleX(${done})` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p className="arrive-count">
|
||||
{total > 0
|
||||
? `${hydrated.toLocaleString()} of ${total.toLocaleString()} messages`
|
||||
: "Counting what is there"}
|
||||
</p>
|
||||
<p className="arrive-quiet">
|
||||
{`Bringing in ${spanOf(arriving.days)}, newest first. Nothing to do here: this closes on its own and opens the account’s Inbox once the mail is in.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Arriving;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button, EmptyState, icons } from "../ui";
|
||||
import type { Clip } from "../ipc";
|
||||
import { useLibrary } from "../store/useLibrary";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useStage } from "../store/useStage";
|
||||
import { displayName } from "./format";
|
||||
import "./list.css";
|
||||
import "./library.css";
|
||||
|
||||
/**
|
||||
* The Clips place: every passage saved out of a message, newest first.
|
||||
*
|
||||
* A clip is a quotation, so it is drawn as one: the words first and where they came from
|
||||
* underneath, rather than a row with the text as a snippet. Each one goes back to the thread it
|
||||
* was taken from, which is the only thing a clip is for.
|
||||
*/
|
||||
const clipDate = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
/** Back to the mail, on the thread the clip came out of. */
|
||||
export function openThread(threadKey: string): void {
|
||||
useStage.getState().close();
|
||||
void useMail.getState().open(threadKey);
|
||||
}
|
||||
|
||||
export function Clips() {
|
||||
const clips = useLibrary((s) => s.clips);
|
||||
const phase = useLibrary((s) => s.clipsPhase);
|
||||
const load = useLibrary((s) => s.loadClips);
|
||||
const remove = useLibrary((s) => s.removeClip);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [accountId, load]);
|
||||
|
||||
return (
|
||||
<main className="stage library">
|
||||
<div className="list-head">
|
||||
<h1 className="list-title">Clips</h1>
|
||||
</div>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<div className="list list-blank">
|
||||
{phase === "loading" ? null : <EmptyState>Nothing saved yet</EmptyState>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="list clips-list">
|
||||
{clips.map((clip) => (
|
||||
<Passage key={clip.id} clip={clip} onDelete={() => void remove(clip.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Passage({ clip, onDelete }: { clip: Clip; onDelete: () => void }) {
|
||||
return (
|
||||
<article className="clip">
|
||||
<button type="button" className="clip-open" onClick={() => openThread(clip.threadKey)}>
|
||||
<blockquote className="clip-text">{clip.text}</blockquote>
|
||||
<div className="clip-meta">
|
||||
<span className="clip-sender">{displayName(clip.sender)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="clip-subject">{clip.subject}</span>
|
||||
<span className="clip-date">{clipDate.format(clip.createdAtMs)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.TRASH}
|
||||
title="Delete this clip"
|
||||
label="Delete this clip"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default Clips;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Palette, type PaletteGroup } from "../ui";
|
||||
import {
|
||||
BINDINGS,
|
||||
keyLabel,
|
||||
PALETTE_COMMANDS,
|
||||
type CommandBinding,
|
||||
type CommandId,
|
||||
} from "../keys/bindings";
|
||||
import { commandMatches, runCommand } from "../keys/commands";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import type { Place } from "../ipc";
|
||||
|
||||
/**
|
||||
* The palette is the only menu in the app and the way every setting is reached, so what is in it
|
||||
* is generated rather than listed: Places are the binding table's place commands and Actions are
|
||||
* every command the table marks as belonging here. A command that exists is in the palette, and
|
||||
* one that is removed leaves it, without anybody remembering to do either.
|
||||
*/
|
||||
const PLACES: readonly CommandBinding[] = BINDINGS.filter(
|
||||
(b): b is CommandBinding => b.command !== null && b.group === "Places",
|
||||
);
|
||||
|
||||
const ACTIONS: readonly CommandBinding[] = PALETTE_COMMANDS.filter((b) => b.group !== "Places");
|
||||
|
||||
/**
|
||||
* A command the palette lists that the keyboard does not reach, so the binding table has no row for
|
||||
* it. Mark all as seen is a row here and a key, and nothing else.
|
||||
*/
|
||||
/** The folders, which are places with no key of their own rather than commands. */
|
||||
const FOLDERS: { id: Place; label: string }[] = [
|
||||
{ id: "sent", label: "Sent" },
|
||||
{ id: "drafts", label: "Drafts" },
|
||||
{ id: "starred", label: "Starred" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The three you go looking in rather than read, in a group of their own under the labels.
|
||||
*
|
||||
* None of them has a number key and none of them is beside the Inbox, because where a place sits
|
||||
* is the honest statement of how often you should be in it. They are still one keystroke and three
|
||||
* letters away, which is what a rescue actually needs.
|
||||
*/
|
||||
const OTHER: { id: Place; label: string }[] = [
|
||||
{ id: "screened-out", label: "Screened out" },
|
||||
{ id: "spam", label: "Spam" },
|
||||
{ id: "trash", label: "Trash" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The first combo a row prints: the bare letter when it is unmodified, glyphs when it is not.
|
||||
*
|
||||
* A command with no keys at all is a real case rather than an oversight: the palette is how a
|
||||
* setting with no verb behind it is reached, and such a row prints nothing on its right.
|
||||
*/
|
||||
const caps = (binding: CommandBinding): string[] => {
|
||||
const combo = binding.keys[0];
|
||||
if (!combo) return [];
|
||||
return [combo.includes("+") ? keyLabel(combo) : combo];
|
||||
};
|
||||
|
||||
const placeOf = (id: string): Place => id.replace(/^place-/, "") as Place;
|
||||
|
||||
export function CommandPalette() {
|
||||
const open = useOverlays((s) => s.open) === "palette";
|
||||
const close = useOverlays((s) => s.close);
|
||||
const goTo = useMail((s) => s.goTo);
|
||||
const goToLabel = useMail((s) => s.goToLabel);
|
||||
const labels = useMail((s) => s.labels);
|
||||
const loadLabels = useMail((s) => s.loadLabels);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(null);
|
||||
// The provider's labels are places, and a place list that is a sync behind is a place list
|
||||
// that sends you somewhere that is not there any more.
|
||||
void loadLabels();
|
||||
}
|
||||
}, [open, loadLabels]);
|
||||
|
||||
const groups = useMemo((): PaletteGroup[] => {
|
||||
const match = (label: string) => commandMatches(label, query);
|
||||
return [
|
||||
{
|
||||
id: "places",
|
||||
label: "Places",
|
||||
items: [
|
||||
...PLACES.filter((b) => match(b.label)).map((b) => ({
|
||||
id: b.command,
|
||||
label: b.label,
|
||||
keys: caps(b),
|
||||
})),
|
||||
...FOLDERS.filter((f) => match(f.label)).map((f) => ({
|
||||
id: `folder:${f.id}`,
|
||||
label: f.label,
|
||||
})),
|
||||
],
|
||||
},
|
||||
// The provider's labels are places. They are the provider's, they roam with the mailbox, and
|
||||
// they are not how Margin organises anything, which is why they are a group of their own
|
||||
// under the places rather than mixed in with them.
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
items: labels
|
||||
.filter((label) => match(label.name))
|
||||
.map((label) => ({ id: `label:${label.id}`, label: label.name })),
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Other",
|
||||
items: OTHER.filter((f) => match(f.label)).map((f) => ({
|
||||
id: `folder:${f.id}`,
|
||||
label: f.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
items: [
|
||||
...ACTIONS.filter((b) => match(b.label)).map((b) => ({
|
||||
id: b.command,
|
||||
label: b.label,
|
||||
keys: caps(b),
|
||||
})),
|
||||
],
|
||||
},
|
||||
// People are the contacts package's, and Settings is the settings screen's: both fill their
|
||||
// group from here when they arrive. An empty group renders as nothing at all.
|
||||
{ id: "people", label: "People", items: [] },
|
||||
{ id: "settings", label: "Settings", items: [] },
|
||||
];
|
||||
}, [query, labels]);
|
||||
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.items.map((i) => i.id)), [groups]);
|
||||
const activeId = active && flat.includes(active) ? active : flat[0];
|
||||
|
||||
const choose = (id: string) => {
|
||||
close();
|
||||
if (id.startsWith("folder:")) goTo(id.slice("folder:".length) as Place);
|
||||
else if (id.startsWith("label:")) {
|
||||
const label = labels.find((candidate) => candidate.id === id.slice("label:".length));
|
||||
if (label) goToLabel(label);
|
||||
} else if (id.startsWith("place-")) goTo(placeOf(id));
|
||||
else runCommand(id as CommandId);
|
||||
};
|
||||
|
||||
// The palette owns the arrow keys and Return while it is open. The field has the focus, so the
|
||||
// app's keymap is already standing back; this is the panel moving its own row.
|
||||
//
|
||||
// No dependency list on purpose: the handler closes over the filtered rows and the active one,
|
||||
// and both change on every keystroke into the field.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const at = flat.indexOf(activeId ?? "");
|
||||
const next = at + (e.key === "ArrowDown" ? 1 : -1);
|
||||
if (next >= 0 && next < flat.length) setActive(flat[next]);
|
||||
} else if (e.key === "Enter" && activeId) {
|
||||
e.preventDefault();
|
||||
choose(activeId);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
return (
|
||||
<Palette
|
||||
open={open}
|
||||
query={query}
|
||||
onQuery={(next) => {
|
||||
setQuery(next);
|
||||
setActive(null);
|
||||
}}
|
||||
groups={groups}
|
||||
activeId={activeId}
|
||||
onChoose={choose}
|
||||
onClose={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandPalette;
|
||||
@@ -0,0 +1,841 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type ClipboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { Avatar, Button, Icon, icons, Key, NO_AUTOFILL, Popover } from "../ui";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { registerCommands } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import { contactsSuggest } from "../api/contacts";
|
||||
import { isTauri, type DraftAttachment, type Person } from "../ipc";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useCompose, type Composer, type ComposerAt } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { Editor } from "./Editor";
|
||||
import { accountHue, cap, displayName, fileKind, fileSize, isBrand } from "./format";
|
||||
import "./compose.css";
|
||||
|
||||
/**
|
||||
* The compose card, and the parts the reply box in the thread is made of too.
|
||||
*
|
||||
* `c` opens a 600px card over the bottom right of the stage with the list still readable behind it,
|
||||
* and `Cmd+Shift+P` makes it the window. It is not a modal and it does not take the stage: writing
|
||||
* a message while looking something up in the list is the ordinary thing to want, and a composer
|
||||
* that blacked out the mailbox behind it would make you close it to check a name.
|
||||
*
|
||||
* The keyboard is taken only while the caret is somewhere inside a composer, which is what lets
|
||||
* `a` still mean reply all on a thread whose reply box is open but not being typed in. The frame
|
||||
* pushed is `editor`, which shadows the view wholesale and leaves `Cmd+K` to TipTap's link.
|
||||
*/
|
||||
|
||||
const CARD_TITLE = "New message";
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// The frame a composer holds while the caret is in it
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
interface Frame {
|
||||
/** Put this on the composer's outermost element. */
|
||||
ref: (el: HTMLElement | null) => void;
|
||||
/** Whether the caret is anywhere inside it. */
|
||||
inside: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard, the commands and the paste and drop handlers a composer owns while it is being
|
||||
* written in.
|
||||
*
|
||||
* Registering the writing commands on focus rather than on open is what keeps two open composers
|
||||
* from fighting over `Cmd+Enter`: the registry runs the last handler registered, and "last" would
|
||||
* otherwise mean whichever box was opened most recently rather than the one being typed in.
|
||||
*/
|
||||
export function useComposerFrame(at: ComposerAt, onSend: (now: boolean) => void): Frame {
|
||||
const [root, setRoot] = useState<HTMLElement | null>(null);
|
||||
const [inside, setInside] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!root) return;
|
||||
const enter = () => setInside(true);
|
||||
const leave = (e: FocusEvent) => {
|
||||
if (!root.contains(e.relatedTarget as Node | null)) setInside(false);
|
||||
};
|
||||
root.addEventListener("focusin", enter);
|
||||
root.addEventListener("focusout", leave);
|
||||
setInside(root.contains(document.activeElement));
|
||||
return () => {
|
||||
root.removeEventListener("focusin", enter);
|
||||
root.removeEventListener("focusout", leave);
|
||||
};
|
||||
}, [root]);
|
||||
|
||||
useKeyContext("editor", inside);
|
||||
|
||||
// A Tauri window answers a drop itself rather than letting the webview see it, which is the whole
|
||||
// reason drag and drop is the one route that carries a real path: a `File` from the webview's own
|
||||
// picker has a name and no path, and `DraftAttachment` wants a path. The size is not in the event,
|
||||
// so it goes in as zero and `DraftSaved.overLimit` is what actually decides whether it fits.
|
||||
useEffect(() => {
|
||||
if (!isTauri || !root) return;
|
||||
const stop = listen<{ paths: string[]; position: { x: number; y: number } }>(
|
||||
"tauri://drag-drop",
|
||||
({ payload }) => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const x = payload.position.x / window.devicePixelRatio;
|
||||
const y = payload.position.y / window.devicePixelRatio;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||
useCompose.getState().attach(
|
||||
at,
|
||||
payload.paths.map((path) => ({
|
||||
path,
|
||||
filename: path.split(/[\\/]/).pop() ?? path,
|
||||
mimeType: "application/octet-stream",
|
||||
size: 0,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
return () => void stop.then((off) => off());
|
||||
}, [at, root]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inside) return;
|
||||
const compose = useCompose.getState();
|
||||
return registerCommands({
|
||||
send: () => onSend(false),
|
||||
"send-now": () => onSend(true),
|
||||
attach: () => pickFiles(at),
|
||||
"instant-intro": () => compose.instantIntro(at),
|
||||
"remind-if-no-reply": () => {
|
||||
const composer = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
|
||||
if (!composer) return;
|
||||
compose.remind(at, composer.draft.remindAtMs ? null : defaultReminder());
|
||||
},
|
||||
"discard-draft": () => void compose.discard(at),
|
||||
"compose-expand": () => compose.toggleExpanded(),
|
||||
});
|
||||
}, [at, inside, onSend]);
|
||||
|
||||
return { ref: setRoot, inside };
|
||||
}
|
||||
|
||||
/** Three days out at nine, which is when a message nobody answered stops being new. */
|
||||
function defaultReminder(): number {
|
||||
const day = new Date();
|
||||
day.setDate(day.getDate() + 3);
|
||||
day.setHours(9, 0, 0, 0);
|
||||
return day.getTime();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Files
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The webview's own picker, which is the only one this app has: there is no dialog plugin in
|
||||
* `src-tauri`, and adding one is a decision for whoever owns that crate.
|
||||
*
|
||||
* A `File` from a webview has a name, a size and a type and no path, and `DraftAttachment` carries
|
||||
* a path or a mirror attachment id and nothing else. So the name goes in the path field, which is
|
||||
* enough for the fixture and for the composer's own arithmetic and is not enough for Rust to read
|
||||
* the bytes. Dragging a file onto a Tauri window is the route that carries a real path.
|
||||
*/
|
||||
export function pickFiles(at: ComposerAt): void {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
input.addEventListener("change", () => {
|
||||
useCompose.getState().attach(at, [...(input.files ?? [])].map(asAttachment));
|
||||
input.remove();
|
||||
});
|
||||
document.body.append(input);
|
||||
input.click();
|
||||
}
|
||||
|
||||
export const asAttachment = (file: File): DraftAttachment => ({
|
||||
path: file.name,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// The card
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export function Compose() {
|
||||
const card = useCompose((s) => s.card);
|
||||
const expanded = useCompose((s) => s.expanded);
|
||||
const holding = useCompose((s) => s.holding);
|
||||
const compose = useCompose((s) => s.compose);
|
||||
const closeCard = useCompose((s) => s.closeCard);
|
||||
const toggleExpanded = useCompose((s) => s.toggleExpanded);
|
||||
|
||||
// `c` and the Write button, for as long as this is mounted, which is the whole life of the app:
|
||||
// this is mounted once at the top of the tree, so writing a message is something you can do from
|
||||
// the Feed and the Screener and not only from a list.
|
||||
useEffect(() => registerCommands({ compose }), [compose]);
|
||||
|
||||
// The signature, the undo delay, the reply-all default and the instant intro line are all
|
||||
// settings, and settings are only read when the settings screen asks for them. Writing is the
|
||||
// other thing that needs them, and it needs them before the first key rather than after it.
|
||||
useEffect(() => {
|
||||
if (!useSettings.getState().settings) void useSettings.getState().load();
|
||||
}, []);
|
||||
|
||||
// `z` inside the delay means the send and not the last triage action, so it is taken over for
|
||||
// exactly as long as there is a send to take back.
|
||||
useEffect(() => {
|
||||
if (!holding) return;
|
||||
return registerCommands({ undo: () => void useCompose.getState().undoSend() });
|
||||
}, [holding]);
|
||||
|
||||
useEscapeLayer(card !== null, closeCard);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<ComposeCard
|
||||
composer={card}
|
||||
expanded={expanded}
|
||||
onClose={closeCard}
|
||||
onExpand={toggleExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposeCardProps {
|
||||
composer: Composer;
|
||||
expanded: boolean;
|
||||
onClose: () => void;
|
||||
onExpand: () => void;
|
||||
}
|
||||
|
||||
function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps) {
|
||||
const send = useCallback((now: boolean) => void useCompose.getState().post("card", now), []);
|
||||
const frame = useComposerFrame("card", send);
|
||||
const draft = composer.draft;
|
||||
const edit = useCompose((s) => s.edit);
|
||||
const setShowCc = useCompose((s) => s.setShowCc);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="compose"
|
||||
data-expanded={expanded ? "" : undefined}
|
||||
role="dialog"
|
||||
aria-label={CARD_TITLE}
|
||||
ref={frame.ref}
|
||||
onDragOver={allowDrop}
|
||||
onDrop={(e) => dropped(e, "card")}
|
||||
onPaste={(e) => pasted(e, "card")}
|
||||
>
|
||||
<div className="compose-head">
|
||||
<h2>{CARD_TITLE}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={expanded ? icons.CHEVRON_DOWN : icons.CHEVRON_UP}
|
||||
title={`${expanded ? "Back to the corner" : "Expand to the window"} (${cap("compose-expand")})`}
|
||||
label={expanded ? "Back to the corner" : "Expand to the window"}
|
||||
onClick={onExpand}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.CLOSE}
|
||||
title="Close, keeping the draft (⎋)"
|
||||
label="Close, keeping the draft"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FromField at="card" draft={draft} />
|
||||
|
||||
<ChipField
|
||||
label="To"
|
||||
people={draft.to}
|
||||
accountId={draft.accountId}
|
||||
autoFocus
|
||||
onChange={(to) => edit("card", { to })}
|
||||
trailing={
|
||||
composer.showCc ? null : (
|
||||
<button type="button" className="more" onClick={() => setShowCc("card", true)}>
|
||||
Cc · Bcc
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{composer.showCc ? (
|
||||
<>
|
||||
<ChipField
|
||||
label="Cc"
|
||||
people={draft.cc ?? []}
|
||||
accountId={draft.accountId}
|
||||
onChange={(cc) => edit("card", { cc })}
|
||||
/>
|
||||
<ChipField
|
||||
label="Bcc"
|
||||
people={draft.bcc ?? []}
|
||||
accountId={draft.accountId}
|
||||
onChange={(bcc) => edit("card", { bcc })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="compose-field">
|
||||
<span className="lab">Subject</span>
|
||||
<input
|
||||
className="compose-subject"
|
||||
value={draft.subject}
|
||||
aria-label="Subject"
|
||||
{...NO_AUTOFILL}
|
||||
onChange={(e) => edit("card", { subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="compose-body">
|
||||
<Editor
|
||||
html={draft.bodyHtml}
|
||||
label="Message"
|
||||
placeholder="Write your message"
|
||||
onChange={(bodyHtml) => edit("card", { bodyHtml })}
|
||||
/>
|
||||
<Attachments at="card" files={draft.attachments ?? []} />
|
||||
</div>
|
||||
|
||||
<ComposeFoot at="card" composer={composer} onSend={send} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// The pieces the reply box in the thread shares
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
export const allowDrop = (e: DragEvent<HTMLElement>): void => {
|
||||
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
|
||||
};
|
||||
|
||||
export function dropped(e: DragEvent<HTMLElement>, at: ComposerAt): void {
|
||||
const files = [...(e.dataTransfer.files ?? [])];
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
useCompose.getState().attach(at, files.map(asAttachment));
|
||||
}
|
||||
|
||||
/**
|
||||
* A pasted image becomes an attachment rather than an inline part, because `DraftAttachment` has a
|
||||
* path or a mirror attachment id and no content id and no inline flag. That is the contract rather
|
||||
* than an oversight to route around here: an inline image needs a `cid:` on both sides.
|
||||
*/
|
||||
export function pasted(e: ClipboardEvent<HTMLElement>, at: ComposerAt): void {
|
||||
const files = [...(e.clipboardData?.files ?? [])];
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
useCompose.getState().attach(at, files.map(asAttachment));
|
||||
}
|
||||
|
||||
interface FromFieldProps {
|
||||
at: ComposerAt;
|
||||
draft: Composer["draft"];
|
||||
}
|
||||
|
||||
/** The address this goes out from: the account, or one of the aliases its settings carry. */
|
||||
function FromField({ at, draft }: FromFieldProps) {
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const edit = useCompose((s) => s.edit);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const account = accounts.find((a) => a.id === draft.accountId) ?? accounts[0];
|
||||
const aliases = settings?.accounts.find((a) => a.accountId === draft.accountId)?.aliases ?? [];
|
||||
const address = draft.fromAlias ?? account?.email ?? "";
|
||||
const choices = account ? [account.email, ...aliases] : [];
|
||||
|
||||
return (
|
||||
<div className="compose-field">
|
||||
<span className="lab">From</span>
|
||||
<div className="chips">
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
ref={setAnchor}
|
||||
aria-label={`Sending from ${address}`}
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
>
|
||||
<Avatar
|
||||
name={account?.name ?? address}
|
||||
address={address}
|
||||
hue={accountHue(account?.color ?? "")}
|
||||
size="xs"
|
||||
/>
|
||||
{address}
|
||||
<Icon d={icons.CHEVRON_DOWN} size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<Popover
|
||||
open={open && choices.length > 1}
|
||||
anchor={anchor}
|
||||
width={280}
|
||||
label="Send from"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<ul className="from-list">
|
||||
{choices.map((choice) => (
|
||||
<li key={choice}>
|
||||
<button
|
||||
type="button"
|
||||
className="from-option"
|
||||
data-on={choice === address ? "" : undefined}
|
||||
onClick={() => {
|
||||
edit(at, { fromAlias: choice === account?.email ? null : choice });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{choice}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipFieldProps {
|
||||
label: string;
|
||||
people: Person[];
|
||||
accountId: string;
|
||||
onChange: (people: Person[]) => void;
|
||||
autoFocus?: boolean;
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** `Maya Raghunathan <maya@…>` and a bare address are both things people paste into a To field. */
|
||||
function parsePerson(text: string): Person | null {
|
||||
const trimmed = text.trim().replace(/[,;]+$/, "").trim();
|
||||
const angled = /^(.*)<([^>]+)>$/.exec(trimmed);
|
||||
if (angled) {
|
||||
const address = angled[2].trim();
|
||||
if (!ADDRESS.test(address)) return null;
|
||||
const name = angled[1].trim().replace(/^["']|["']$/g, "");
|
||||
return { name: name || null, address };
|
||||
}
|
||||
return ADDRESS.test(trimmed) ? { name: null, address: trimmed } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipients as chips, with autocomplete over the mirror.
|
||||
*
|
||||
* `contactsSuggest` reads what is already on this device first and never sends an address anywhere
|
||||
* to be looked up, which is the whole reason a mail client is allowed to have an address book.
|
||||
*/
|
||||
export function ChipField({
|
||||
label,
|
||||
people,
|
||||
accountId,
|
||||
onChange,
|
||||
autoFocus,
|
||||
trailing,
|
||||
}: ChipFieldProps) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Person[]>([]);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const taken = useMemo(
|
||||
() => new Set(people.map((p) => p.address.toLowerCase())),
|
||||
[people],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = typed.trim();
|
||||
if (prefix.length < 1) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
const timer = window.setTimeout(() => {
|
||||
void contactsSuggest(accountId, prefix)
|
||||
.then((found) => {
|
||||
if (!live) return;
|
||||
setSuggestions(found.filter((p) => !taken.has(p.address.toLowerCase())).slice(0, 6));
|
||||
setHighlight(0);
|
||||
})
|
||||
.catch(() => setSuggestions([]));
|
||||
}, 90);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [typed, accountId, taken]);
|
||||
|
||||
const add = (person: Person) => {
|
||||
if (taken.has(person.address.toLowerCase())) return;
|
||||
onChange([...people, person]);
|
||||
setTyped("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const commit = (): boolean => {
|
||||
const chosen = suggestions[highlight];
|
||||
if (chosen) {
|
||||
add(chosen);
|
||||
return true;
|
||||
}
|
||||
const typedPerson = parsePerson(typed);
|
||||
if (typedPerson) {
|
||||
add(typedPerson);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="compose-field">
|
||||
<span className="lab">{label}</span>
|
||||
<div className="chips" onClick={() => input.current?.focus()}>
|
||||
{people.map((person, index) => (
|
||||
<span className="chip" key={person.address}>
|
||||
<Avatar
|
||||
name={displayName(person)}
|
||||
address={person.address}
|
||||
brand={isBrand(person)}
|
||||
size="xs"
|
||||
/>
|
||||
{displayName(person)}
|
||||
<button
|
||||
type="button"
|
||||
className="chip-off"
|
||||
title={`Take ${displayName(person)} off`}
|
||||
aria-label={`Take ${displayName(person)} off`}
|
||||
onClick={() => onChange(people.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Icon d={icons.CLOSE} size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={input}
|
||||
className="chip-input"
|
||||
value={typed}
|
||||
aria-label={label}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onBlur={() => {
|
||||
// What was typed and not what was highlighted: leaving the field is not choosing from
|
||||
// a list, it is finishing an address.
|
||||
const typedPerson = parsePerson(typed);
|
||||
if (typedPerson) add(typedPerson);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" && suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
setHighlight((was) => Math.min(was + 1, suggestions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp" && suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
setHighlight((was) => Math.max(was - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
|
||||
// Tab with nothing typed is still Tab: moving on is what it means everywhere else.
|
||||
if (e.key === "Tab" && typed.trim() === "") return;
|
||||
if (commit()) e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && typed === "" && people.length > 0) {
|
||||
e.preventDefault();
|
||||
onChange(people.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{trailing}
|
||||
{suggestions.length > 0 ? (
|
||||
<ul className="suggest" role="listbox" aria-label={`${label} suggestions`}>
|
||||
{suggestions.map((person, index) => (
|
||||
<li key={person.address}>
|
||||
<button
|
||||
type="button"
|
||||
className="suggest-row"
|
||||
role="option"
|
||||
aria-selected={index === highlight}
|
||||
data-on={index === highlight ? "" : undefined}
|
||||
// The blur that a click causes would clear the list before the click landed.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => add(person)}
|
||||
>
|
||||
<Avatar
|
||||
name={displayName(person)}
|
||||
address={person.address}
|
||||
brand={isBrand(person)}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="suggest-name">{displayName(person)}</span>
|
||||
<span className="suggest-addr">{person.address}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentsProps {
|
||||
at: ComposerAt;
|
||||
files: DraftAttachment[];
|
||||
}
|
||||
|
||||
export function Attachments({ at, files }: AttachmentsProps) {
|
||||
const detach = useCompose((s) => s.detach);
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
<div className="attachments">
|
||||
{files.map((file, index) => (
|
||||
<span className="attachment" key={`${file.filename}-${index}`}>
|
||||
<span className="ext">{fileKind(file.filename, file.mimeType)}</span>
|
||||
{file.filename}
|
||||
<span className="size">{fileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chip-off"
|
||||
title={`Take ${file.filename} off`}
|
||||
aria-label={`Take ${file.filename} off`}
|
||||
onClick={() => detach(at, index)}
|
||||
>
|
||||
<Icon d={icons.CLOSE} size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposeFootProps {
|
||||
at: ComposerAt;
|
||||
composer: Composer;
|
||||
onSend: (now: boolean) => void;
|
||||
/** The one control the thread's reply box has that the card does not. */
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
const asDateInput = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send with its key, the reminder, the paperclip, and the delay said in words.
|
||||
*
|
||||
* The delay is printed rather than assumed because it is a setting: somebody who moved it to thirty
|
||||
* seconds has to be able to see that they did, and somebody who moved it to five has to know that
|
||||
* pressing Send means it.
|
||||
*/
|
||||
export function ComposeFoot({ at, composer, onSend, extra }: ComposeFootProps) {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const remind = useCompose((s) => s.remind);
|
||||
const delay = settings?.undoDelaySecs ?? 10;
|
||||
const remindAt = composer.draft.remindAtMs ?? null;
|
||||
|
||||
return (
|
||||
<div className="compose-foot">
|
||||
<Button variant="primary" keycap={cap("send")} onClick={() => onSend(false)}>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
active={remindAt !== null}
|
||||
title={`Remind me if no reply (${cap("remind-if-no-reply")})`}
|
||||
onClick={() => remind(at, remindAt === null ? defaultReminder() : null)}
|
||||
>
|
||||
Remind me if no reply
|
||||
</Button>
|
||||
{remindAt !== null ? (
|
||||
<input
|
||||
className="compose-date"
|
||||
type="date"
|
||||
aria-label="Remind me on"
|
||||
{...NO_AUTOFILL}
|
||||
value={asDateInput(remindAt)}
|
||||
onChange={(e) => {
|
||||
const chosen = new Date(`${e.target.value}T09:00`);
|
||||
if (!Number.isNaN(chosen.getTime())) remind(at, chosen.getTime());
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className="spacer" />
|
||||
{composer.overLimit ? (
|
||||
<span className="compose-warn">Over the 35 MB the provider takes</span>
|
||||
) : null}
|
||||
{extra}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.PAPERCLIP}
|
||||
title={`Attach a file (${cap("attach")})`}
|
||||
label="Attach a file"
|
||||
onClick={() => pickFiles(at)}
|
||||
/>
|
||||
<span className="compose-delay">{`Undo send: ${delay} s`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReplyBoxProps {
|
||||
/** The person a plain reply goes to, which is what the head names. */
|
||||
to: Person;
|
||||
composer: Composer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box under the last message of a thread. Not the compose card: the mockups are clear that a
|
||||
* reply is written in the thread, under what it answers, at the thread's own measure.
|
||||
*/
|
||||
export function ReplyBox({ to, composer }: ReplyBoxProps) {
|
||||
const send = useCallback((now: boolean) => void useCompose.getState().post("reply", now), []);
|
||||
const frame = useComposerFrame("reply", send);
|
||||
const edit = useCompose((s) => s.edit);
|
||||
const setAll = useCompose((s) => s.setAll);
|
||||
const setShowCc = useCompose((s) => s.setShowCc);
|
||||
const closeReply = useCompose((s) => s.closeReply);
|
||||
const editor = useRef<TiptapEditor | null>(null);
|
||||
const draft = composer.draft;
|
||||
|
||||
// Escape leaves the editor and keeps the draft, which is docs/keyboard.md's own wording. The
|
||||
// second Escape is not this box's: it belongs to whatever is under it.
|
||||
useEscapeLayer(frame.inside, () => {
|
||||
editor.current?.commands.blur();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
});
|
||||
|
||||
const forwarding = composer.kind === "forward";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="reply"
|
||||
ref={frame.ref}
|
||||
onDragOver={allowDrop}
|
||||
onDrop={(e) => dropped(e, "reply")}
|
||||
onPaste={(e) => pasted(e, "reply")}
|
||||
>
|
||||
<div className="reply-head">
|
||||
<span className="reply-who">
|
||||
{forwarding ? "Forward" : "Reply to "}
|
||||
{forwarding ? null : <b>{displayName(to)}</b>}
|
||||
</span>
|
||||
{forwarding ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="reply-all"
|
||||
data-on={composer.all ? "" : undefined}
|
||||
onClick={() => setAll(!composer.all)}
|
||||
>
|
||||
<Key size="sm">{cap("reply-all") ?? "a"}</Key>
|
||||
reply all
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="reply-close"
|
||||
title="Close, keeping the draft"
|
||||
aria-label="Close, keeping the draft"
|
||||
onClick={closeReply}
|
||||
>
|
||||
<Icon d={icons.CLOSE} size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChipField
|
||||
label="To"
|
||||
people={draft.to}
|
||||
accountId={draft.accountId}
|
||||
autoFocus={forwarding}
|
||||
onChange={(to) => edit("reply", { to })}
|
||||
trailing={
|
||||
composer.showCc ? null : (
|
||||
<button type="button" className="more" onClick={() => setShowCc("reply", true)}>
|
||||
Cc · Bcc
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{composer.showCc ? (
|
||||
<>
|
||||
<ChipField
|
||||
label="Cc"
|
||||
people={draft.cc ?? []}
|
||||
accountId={draft.accountId}
|
||||
onChange={(cc) => edit("reply", { cc })}
|
||||
/>
|
||||
<ChipField
|
||||
label="Bcc"
|
||||
people={draft.bcc ?? []}
|
||||
accountId={draft.accountId}
|
||||
onChange={(bcc) => edit("reply", { bcc })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="reply-body">
|
||||
<Editor
|
||||
html={draft.bodyHtml}
|
||||
label="Reply"
|
||||
placeholder="Write a reply"
|
||||
autoFocus={!forwarding}
|
||||
onChange={(bodyHtml) => edit("reply", { bodyHtml })}
|
||||
onReady={(instance) => {
|
||||
editor.current = instance;
|
||||
}}
|
||||
/>
|
||||
<Attachments at="reply" files={draft.attachments ?? []} />
|
||||
</div>
|
||||
|
||||
<ComposeFoot at="reply" composer={composer} onSend={send} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The line a thread carries while something of its own is still in the outbox.
|
||||
*
|
||||
* `ThreadSummary.sending` is the fact and the summary is the list's, so the pane reads the row it
|
||||
* came from rather than being told twice.
|
||||
*/
|
||||
export function SendingLine({ threadKey }: { threadKey: string }) {
|
||||
const sending = useMail((s) => s.threads.find((t) => t.key === threadKey)?.sending ?? false);
|
||||
const flush = useCompose((s) => s.flush);
|
||||
const phase = useCompose((s) => s.flushPhase);
|
||||
if (!sending) return null;
|
||||
const busy = phase === "sending";
|
||||
return (
|
||||
<div className="sending" data-phase={phase}>
|
||||
<Icon d={icons.CLOCK} size={13} />
|
||||
<span>Waiting to send</span>
|
||||
<button type="button" className="sending-now" disabled={busy} onClick={() => void flush()}>
|
||||
{busy ? "Sending" : "Send now"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Compose;
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "../ui";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useSync } from "../store/useSync";
|
||||
import { ConnectMail, ConnectMailServers } from "./ConnectMail";
|
||||
import { spanOf, WindowChoice } from "./WindowChoice";
|
||||
import { quietDoubleClick } from "./Header";
|
||||
import { permissionName } from "./Settings";
|
||||
import "./connect.css";
|
||||
|
||||
/**
|
||||
* The first screen, and the only one that is not the app.
|
||||
*
|
||||
* Four states on one stage: the welcome, the wait for a browser that may not have come to the
|
||||
* front, a consent page that came back without the permission all of this is built on, and the
|
||||
* first sync arriving. Two of those are not failures. Closing the consent tab and withholding a
|
||||
* scope are both answers, and the screen takes them as answers rather than apologising.
|
||||
*
|
||||
* The welcome itself is one field, the address, and it belongs to `ConnectMail`: that flow reads
|
||||
* the domain and works out whether the browser, a password or a sentence comes next. What this file
|
||||
* owns is the Google half once it has been handed over, which is `useAccounts`, and the one
|
||||
* judgement about the sync: when to stop showing it and show the mail.
|
||||
*/
|
||||
/** Phases a first sync passes through on its way to mail. */
|
||||
const WORKING = new Set(["syncing", "hydrating", "backfilling"]);
|
||||
/** Phases it stops on without mail, each of which has something to say for itself. */
|
||||
const STOPPED = new Set(["error", "offline", "paused"]);
|
||||
|
||||
export function Connect() {
|
||||
const phase = useAccounts((s) => s.phase);
|
||||
const error = useAccounts((s) => s.error);
|
||||
const authUrl = useAccounts((s) => s.authUrl);
|
||||
const missing = useAccounts((s) => s.missingRequired);
|
||||
const pendingAccountId = useAccounts((s) => s.pendingAccountId);
|
||||
const pendingDays = useAccounts((s) => s.pendingDays);
|
||||
const starting = useAccounts((s) => s.starting);
|
||||
const startSync = useAccounts((s) => s.startSync);
|
||||
const connect = useAccounts((s) => s.connect);
|
||||
const cancelConnect = useAccounts((s) => s.cancelConnect);
|
||||
const openAuthUrl = useAccounts((s) => s.openAuthUrl);
|
||||
const copyAuthUrl = useAccounts((s) => s.copyAuthUrl);
|
||||
const finishConnect = useAccounts((s) => s.finishConnect);
|
||||
const statuses = useSync((s) => s.statuses);
|
||||
const retry = useSync((s) => s.run);
|
||||
const threads = useMail((s) => s.threads);
|
||||
|
||||
// While the browser has the flow, Escape belongs to the flow. Nothing to escape from once the
|
||||
// mail is on its way: the sync does not stop because somebody pressed a key at it.
|
||||
useEscapeLayer(phase === "connecting" || phase === "refused", cancelConnect);
|
||||
|
||||
const status = statuses.find((s) => s.accountId === pendingAccountId) ?? statuses[0] ?? null;
|
||||
const failed = status !== null && STOPPED.has(status.phase);
|
||||
// No status at all means the first pass has not reported yet, which is a kind of working.
|
||||
const working = status === null || WORKING.has(status.phase);
|
||||
|
||||
// The Inbox arrives when the first page of threads does, not when the sync finishes: the rest
|
||||
// fills in behind it and nobody should have to watch that. A mailbox whose first pass finds
|
||||
// nothing at all has no first page to wait for, so a pass that ended hands over too.
|
||||
//
|
||||
// The handover is read off the phase alone rather than off having watched the phase change.
|
||||
// A pass that fails in its first second emits "syncing" and then its failure inside one React
|
||||
// batch, so a flag set on the way past is never set, and the screen used to sit on "Listing
|
||||
// your mail" for ever with the reason two layers down in a status nobody rendered.
|
||||
useEffect(() => {
|
||||
if (phase !== "syncing" || failed) return;
|
||||
if (threads.length > 0 || !working) void finishConnect();
|
||||
}, [phase, working, failed, threads.length, finishConnect]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Empty, but it is still the strip under the traffic lights, and the window has to be
|
||||
draggable by it before there is an account to put in it. */}
|
||||
<header className="titlebar" data-tauri-drag-region onMouseDown={quietDoubleClick} />
|
||||
<main className="stage">
|
||||
<div className="welcome">
|
||||
<svg
|
||||
className="welcome-mark"
|
||||
width={54}
|
||||
height={54}
|
||||
viewBox="0 0 54 54"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect className="welcome-plate" width="54" height="54" rx="13" />
|
||||
<rect className="welcome-glyph" x="14" y="18.5" width="26" height="17" rx="3.2" />
|
||||
<path className="welcome-glyph" d="m14.8 20.5 10.5 7.6a3 3 0 0 0 3.4 0l10.5-7.6" />
|
||||
</svg>
|
||||
|
||||
{phase === "choosing" ? (
|
||||
<Choosing initial={30} busy={starting} onStart={(days) => void startSync(days)} />
|
||||
) : null}
|
||||
|
||||
{phase === "syncing" ? (
|
||||
failed ? (
|
||||
<Stalled
|
||||
status={status}
|
||||
onRetry={() => void retry(status?.accountId)}
|
||||
onSkip={() => void finishConnect()}
|
||||
/>
|
||||
) : (
|
||||
<Progress status={status} days={pendingDays} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{phase === "connecting" ? (
|
||||
<Waiting
|
||||
ready={authUrl !== null}
|
||||
onOpen={openAuthUrl}
|
||||
onCopy={() => void copyAuthUrl()}
|
||||
onCancel={cancelConnect}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase === "refused" ? (
|
||||
<Refused scopes={missing} onRetry={() => void connect()} onCancel={cancelConnect} />
|
||||
) : null}
|
||||
|
||||
{phase === "idle" || phase === "error" ? (
|
||||
<ConnectMail
|
||||
onGoogle={(email) => void connect(email)}
|
||||
trouble={phase === "error" ? error : null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* The servers and the certificate question take the window rather than a place in the
|
||||
column, so they hang off the screen rather than out of the panel that opened them. The
|
||||
welcome column centres its text, and an overlay inside it would inherit that. */}
|
||||
<ConnectMailServers />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Waiting({
|
||||
ready,
|
||||
onOpen,
|
||||
onCopy,
|
||||
onCancel,
|
||||
}: {
|
||||
ready: boolean;
|
||||
onOpen: () => void;
|
||||
onCopy: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="welcome-title">Waiting for Google</h1>
|
||||
<p className="welcome-line">
|
||||
{ready
|
||||
? "Sign in there and this screen comes back on its own. The browser that opened is not always the one in front of you: open the link again, or copy it into the one you are signed in to Google in."
|
||||
: "Building the sign-in link."}
|
||||
</p>
|
||||
|
||||
<div className="welcome-actions">
|
||||
<Button disabled={!ready} onClick={onOpen}>
|
||||
Open link again
|
||||
</Button>
|
||||
<Button disabled={!ready} onClick={onCopy}>
|
||||
Copy link
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="welcome-privacy">{PRIVACY}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Quoted in docs/ui.md and in the browser suite. This sentence is the promise the sign-in is
|
||||
* asking to be trusted on, and it is said once, on the screen that is waiting for it. */
|
||||
const PRIVACY =
|
||||
"Sign-in happens in your browser with Google. Margin never sees your password. The key Google " +
|
||||
"hands back is stored only on this device, and you can revoke it any time from your Google account.";
|
||||
|
||||
function Refused({
|
||||
scopes,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: {
|
||||
scopes: string[];
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const names = scopes.map(permissionName).join(", ");
|
||||
return (
|
||||
<>
|
||||
<h1 className="welcome-title">One permission is missing</h1>
|
||||
<p className="welcome-line">
|
||||
Google came back without “{names}”, which is the one thing a mail client cannot work
|
||||
without, so no account was added and nothing was stored.
|
||||
</p>
|
||||
|
||||
<div className="welcome-actions">
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="welcome-privacy">
|
||||
Every other permission is optional and can be granted later from Settings. This is the only
|
||||
one the app is built on.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Arrived {
|
||||
accountId?: string;
|
||||
phase?: string;
|
||||
error?: string | null;
|
||||
hydrated: number;
|
||||
total: number;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A first sync that stopped without any mail, and the reason it gave.
|
||||
*
|
||||
* The account is connected and its token is on disk by the time this can appear, so the reason is
|
||||
* almost never about the sign-in: it is the provider refusing a call, a network that is not there,
|
||||
* or the breaker having given up. Whatever it was, the provider's own sentence is shown verbatim,
|
||||
* because Google's refusals name the thing to fix and a sentence written here would not.
|
||||
*
|
||||
* Two ways on. Trying again is the usual one. Going in anyway is for a mailbox that is going to
|
||||
* stay broken for a while: the app works, the mirror is empty, and the account chip keeps saying
|
||||
* so. Being held on a screen with one button is worse than an empty Inbox that explains itself.
|
||||
*/
|
||||
function Stalled({
|
||||
status,
|
||||
onRetry,
|
||||
onSkip,
|
||||
}: {
|
||||
status: Arrived | null;
|
||||
onRetry: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const offline = status?.phase === "offline";
|
||||
const said = status?.error ?? status?.message ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="welcome-title">{offline ? "No connection" : "Your mail did not arrive"}</h1>
|
||||
<p className="welcome-line">
|
||||
{offline
|
||||
? "The account is connected and nothing was lost. The mail comes in as soon as there is a network."
|
||||
: "The account is connected and its permissions are stored. The mailbox itself refused the first request."}
|
||||
</p>
|
||||
|
||||
{said ? <p className="welcome-trouble">{said}</p> : null}
|
||||
|
||||
<div className="welcome-actions">
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onSkip}>
|
||||
Go in anyway
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="welcome-privacy">
|
||||
Nothing here is lost by waiting. The sync picks up where it stopped, and the account chip in
|
||||
the corner says so until it does.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one question between consent and the mail: how far back this device holds. The account is
|
||||
* written by now, so there is nothing to cancel and no way back; the answer starts the sync.
|
||||
*/
|
||||
function Choosing({
|
||||
initial,
|
||||
busy,
|
||||
onStart,
|
||||
}: {
|
||||
initial: number;
|
||||
busy: boolean;
|
||||
onStart: (days: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="welcome-title">How far back?</h1>
|
||||
<p className="welcome-line">
|
||||
Choose how much of your mail this device holds. The first sync brings in that much, newest
|
||||
first, and older mail stays where it is until you search for it.
|
||||
</p>
|
||||
<WindowChoice initial={initial} busy={busy} onStart={onStart} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ status, days }: { status: Arrived | null; days: number }) {
|
||||
const total = status?.total ?? 0;
|
||||
const hydrated = Math.min(status?.hydrated ?? 0, total);
|
||||
const done = total > 0 ? hydrated / total : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="welcome-title">{`Bringing in ${spanOf(days)}`}</h1>
|
||||
<p className="welcome-line">{status?.message ?? "Listing your mail"}</p>
|
||||
|
||||
<div
|
||||
className="welcome-bar"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(done * 100)}
|
||||
>
|
||||
<span className="welcome-bar-fill" style={{ transform: `scaleX(${done})` }} />
|
||||
</div>
|
||||
|
||||
<p className="welcome-count">
|
||||
{total > 0
|
||||
? `${hydrated.toLocaleString()} of ${total.toLocaleString()} messages`
|
||||
: "Counting what is there"}
|
||||
</p>
|
||||
|
||||
<p className="welcome-privacy">
|
||||
This becomes your Inbox as soon as the first mail lands. The rest arrives behind it.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Connect;
|
||||
@@ -0,0 +1,644 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Banner, Button, Field, Segment, Sheet, icons } from "../ui";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import type { MailConfig, Security, ServerConfig } from "../ipc";
|
||||
import { closedName, domainOf, passwordHint, providerName } from "../providers";
|
||||
import { adviceFor, useImapConnect } from "../store/useImapConnect";
|
||||
import "./connectmail.css";
|
||||
|
||||
/**
|
||||
* Adding an account, from the address onwards.
|
||||
*
|
||||
* One question first, the address, and no provider to pick before it: a person knows their address
|
||||
* and does not always know who runs the mailbox behind it, and a screen that opens with a Google
|
||||
* button and an "anything else" button underneath has already decided who it was built for. From
|
||||
* the address the flow works out which of three things comes next. A Google mailbox, including a
|
||||
* work domain that turns out to be one, goes to the browser and this component is done with it. A
|
||||
* Microsoft mailbox gets a sentence saying why it cannot be reached. Everything else gets a sign-in
|
||||
* panel: the servers discovery found, said out loud before the password is typed into them, and
|
||||
* then a name and that password.
|
||||
*
|
||||
* There is no "pick your provider" step and no fork between an easy path and an expert one, which
|
||||
* is where both Thunderbird and Mailspring ended up after years of having one. The servers sheet is
|
||||
* the same sheet whether it was opened to correct what was found or to type what was not.
|
||||
*
|
||||
* The three questions this screen has to answer honestly are all about trust rather than about
|
||||
* mail: where these settings came from, what kind of password this provider wants, and whether a
|
||||
* certificate nobody vouches for should be accepted. Each of those is said in its place.
|
||||
*/
|
||||
export function ConnectMail({ host = "stage", onGoogle, trouble = null }: ConnectMailProps) {
|
||||
const phase = useImapConnect((s) => s.phase);
|
||||
const back = useImapConnect((s) => s.back);
|
||||
|
||||
// The sheets register their own layers, and in the other host the panel around this one already
|
||||
// owns Escape. On the stage the address step is the floor: there is nothing under it to go back
|
||||
// to, so Escape there does nothing rather than clearing what was typed.
|
||||
useEscapeLayer(host === "stage" && (phase === "password" || phase === "unsupported"), back);
|
||||
|
||||
if (phase === "unsupported") return <Unsupported host={host} />;
|
||||
// The servers and the certificate are sheets over this panel, so it stays where it was under
|
||||
// them, and Escape from either lands back on it with nothing to redraw.
|
||||
if (phase === "password" || phase === "manual" || phase === "cert") {
|
||||
return <SignIn host={host} />;
|
||||
}
|
||||
return <Address host={host} onGoogle={onGoogle} trouble={trouble} />;
|
||||
}
|
||||
|
||||
export interface ConnectMailProps {
|
||||
host?: Host;
|
||||
/**
|
||||
* A Google address, recognised from its domain or from the servers it was found on. The browser
|
||||
* flow belongs to `useAccounts`, and which of its two entry points to use is the host's to say.
|
||||
*/
|
||||
onGoogle: (email: string) => void;
|
||||
/** Something the host has to say on the address step: the consent URL not being buildable. */
|
||||
trouble?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the flow is being drawn. The welcome screen gives it the stage; Settings has a stage of its
|
||||
* own already, so there it is a sheet over the account list.
|
||||
*/
|
||||
export type Host = "stage" | "sheet";
|
||||
|
||||
/**
|
||||
* What the flow calls itself at each step.
|
||||
*
|
||||
* On the stage it is a heading and in a sheet it is the panel's head, so it is a function rather
|
||||
* than a literal: the words are the flow's either way, and a second copy of them written into
|
||||
* Settings is how the two would come to disagree the next time one of them is reworded. The
|
||||
* address step is the one place the two hosts differ, because on the stage it is the wordmark.
|
||||
*/
|
||||
export function connectMailTitle(
|
||||
phase: string,
|
||||
config: MailConfig | null,
|
||||
email: string,
|
||||
blocked: "microsoft" | "closed" | null = null,
|
||||
): string {
|
||||
if (phase === "unsupported") {
|
||||
return blocked === "closed" ? `${closedName(email)} has no way in` : "Outlook is not here yet";
|
||||
}
|
||||
if (phase === "password" || phase === "manual" || phase === "cert") {
|
||||
return `Sign in to ${providerName(config, email)}`;
|
||||
}
|
||||
return "Add an account";
|
||||
}
|
||||
|
||||
/** The step's name, drawn here on the stage and left to the panel's own head in a sheet. */
|
||||
function Title({ host, children }: { host: Host; children: string }) {
|
||||
if (host === "sheet") return null;
|
||||
return <h1 className="welcome-title">{children}</h1>;
|
||||
}
|
||||
|
||||
/** The one sentence that names what works, said where somebody is deciding whether to type. */
|
||||
const WORKS_WITH =
|
||||
"Any mailbox works here: Google, Fastmail, iCloud, Yahoo, Proton through Bridge, a mailbox " +
|
||||
"where you work, or anything else that speaks IMAP.";
|
||||
|
||||
/**
|
||||
* The address, which is the whole of the first step.
|
||||
*
|
||||
* Nothing else is asked yet because nothing else is known yet: a Google account has no password to
|
||||
* give and brings its own name, and a mailbox on a password does not need its name until the
|
||||
* servers are known. The button says what it is doing while the lookup is out, because a press
|
||||
* that changes nothing on screen reads as a press that did nothing.
|
||||
*/
|
||||
function Address({ host, onGoogle, trouble }: { host: Host } & ConnectMailProps) {
|
||||
const email = useImapConnect((s) => s.email);
|
||||
const work = useImapConnect((s) => s.work);
|
||||
const error = useImapConnect((s) => s.error);
|
||||
const setEmail = useImapConnect((s) => s.setEmail);
|
||||
const lookup = useImapConnect((s) => s.lookup);
|
||||
const stopLookup = useImapConnect((s) => s.stopLookup);
|
||||
const skipLookup = useImapConnect((s) => s.skipLookup);
|
||||
|
||||
const looking = work === "looking";
|
||||
const domain = domainOf(email);
|
||||
|
||||
const go = async () => {
|
||||
const route = await lookup();
|
||||
if (route === "google") onGoogle(email.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title host={host}>Margin Mail</Title>
|
||||
{host === "stage" ? (
|
||||
<p className="welcome-line">
|
||||
A quiet, keyboard-first client for your mail, where every decision you make stays on your
|
||||
own machine.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="imap-address"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void go();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Email address"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
disabled={looking}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="imap-go">
|
||||
<Button variant="primary" type="submit" disabled={looking}>
|
||||
{looking ? `Looking up ${domain}` : "Continue"}
|
||||
</Button>
|
||||
{/* A lookup asks four places and then tries the usual names, and a domain that publishes
|
||||
nothing makes every one of them wait out its timeout. The person is not held to that. */}
|
||||
{looking ? (
|
||||
<Button variant="ghost" onClick={stopLookup}>
|
||||
Stop
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <p className="welcome-trouble">{error}</p> : null}
|
||||
{trouble ? <p className="welcome-trouble">{trouble}</p> : null}
|
||||
|
||||
<p className="welcome-privacy">
|
||||
{WORKS_WITH} Margin works out the servers from the address, and nothing is stored until the
|
||||
account is added.
|
||||
</p>
|
||||
|
||||
{!looking ? (
|
||||
<p className="imap-aside">
|
||||
<Button variant="ghost" onClick={skipLookup}>
|
||||
Enter the servers myself
|
||||
</Button>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers discovery found, and the two things they need: a name and a password.
|
||||
*
|
||||
* `source` is the whole reason the first sentence exists. A configuration the provider publishes
|
||||
* and one guessed by trying the usual names are different promises to somebody about to type a
|
||||
* password, and the only honest thing to do is say which of the two this is before they do.
|
||||
*
|
||||
* The hint under the password is the other honest thing. Half the providers on the internet want a
|
||||
* password made for the purpose rather than the one on the website, and every one of them refuses
|
||||
* the wrong one in its own vocabulary. Saying which to type, before it is typed, is worth more
|
||||
* than any sentence after the refusal.
|
||||
*/
|
||||
function SignIn({ host }: { host: Host }) {
|
||||
const phase = useImapConnect((s) => s.phase);
|
||||
const config = useImapConnect((s) => s.config);
|
||||
const email = useImapConnect((s) => s.email);
|
||||
const name = useImapConnect((s) => s.name);
|
||||
const password = useImapConnect((s) => s.password);
|
||||
const work = useImapConnect((s) => s.work);
|
||||
const discovery = useImapConnect((s) => s.discovery);
|
||||
const report = useImapConnect((s) => s.report);
|
||||
const error = useImapConnect((s) => s.error);
|
||||
const setName = useImapConnect((s) => s.setName);
|
||||
const setPassword = useImapConnect((s) => s.setPassword);
|
||||
const submit = useImapConnect((s) => s.submit);
|
||||
const openManual = useImapConnect((s) => s.openManual);
|
||||
const back = useImapConnect((s) => s.back);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const busy = work !== "idle";
|
||||
const empty = discovery === "empty" || discovery === "skipped";
|
||||
// A refusal is explained on whichever panel is in front. With the servers sheet open over this
|
||||
// one it is the sheet's to explain, and the same sentence twice on one screen is not twice as
|
||||
// clear.
|
||||
const front = phase === "password";
|
||||
const refused = front && report !== null && !report.ok && report.cert === null;
|
||||
const leg = report?.failed === "smtp" ? config.smtp : config.imap;
|
||||
const advice = front ? adviceFor(config, report) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title host={host}>{connectMailTitle("password", config, email)}</Title>
|
||||
<p className="welcome-line">{provenance(config, email, discovery)}</p>
|
||||
|
||||
{!empty ? (
|
||||
<dl className="imap-facts">
|
||||
<Fact label="Incoming" value={serverLineAs(config.imap, email)} />
|
||||
<Fact label="Outgoing" value={serverLineAs(config.smtp, email)} />
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="imap-form"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Your name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
hint="What people see beside your address on the mail you send."
|
||||
/>
|
||||
<Field
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
disabled={busy}
|
||||
hint={passwordHint(config) ?? undefined}
|
||||
/>
|
||||
|
||||
<div className="imap-actions">
|
||||
<Button variant="primary" type="submit" disabled={busy}>
|
||||
{empty
|
||||
? "Enter the servers"
|
||||
: work === "checking"
|
||||
? "Checking"
|
||||
: work === "adding"
|
||||
? "Adding"
|
||||
: "Add account"}
|
||||
</Button>
|
||||
{!empty ? (
|
||||
<Button variant="ghost" disabled={busy} onClick={openManual}>
|
||||
Change the servers
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" disabled={busy} onClick={back}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{refused ? (
|
||||
<p className="welcome-trouble">
|
||||
{report.failed === "smtp"
|
||||
? `Your mail came through. Sending, through ${leg.host}, did not.`
|
||||
: `Signing in to ${leg.host} did not work.`}
|
||||
</p>
|
||||
) : null}
|
||||
{refused && report.message ? <p className="imap-said">{report.message}</p> : null}
|
||||
{advice ? <p className="imap-advice">{advice}</p> : null}
|
||||
{front && error ? <p className="welcome-trouble">{error}</p> : null}
|
||||
|
||||
<p className="welcome-privacy">
|
||||
Nothing is stored until these servers accept the password. Then it is encrypted on this
|
||||
device beside the keys the app already holds, and it goes to them and to nobody else.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mailbox this app cannot open, said plainly rather than with a button that fails.
|
||||
*
|
||||
* Two kinds. Microsoft turned password sign-in off for its mailboxes, personal and business both,
|
||||
* app passwords included, and the sign-in it wants instead is an OAuth client registered with
|
||||
* Microsoft, which Margin does not have. HEY and Tuta have no IMAP at all, by their own account,
|
||||
* so no mail client can open them. A panel that took a password for either and reported a refusal
|
||||
* would be a panel that knew better.
|
||||
*/
|
||||
function Unsupported({ host }: { host: Host }) {
|
||||
const email = useImapConnect((s) => s.email);
|
||||
const blocked = useImapConnect((s) => s.blocked);
|
||||
const back = useImapConnect((s) => s.back);
|
||||
const domain = domainOf(email) || "This address";
|
||||
const closed = blocked === "closed";
|
||||
const brand = closedName(email);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title host={host}>{connectMailTitle("unsupported", null, email, blocked)}</Title>
|
||||
<p className="welcome-line">
|
||||
{closed
|
||||
? `${brand} does not let any mail client sign in: there is no IMAP, no POP and no other ` +
|
||||
`way in, so this mailbox can only be read in ${brand}'s own app. Nothing was stored.`
|
||||
: `${domain} is a Microsoft mailbox. Microsoft turned off password sign-in to Outlook, ` +
|
||||
"Hotmail and Microsoft 365 in 2024, app passwords included, and the sign-in it wants " +
|
||||
"instead needs an app registration with Microsoft that Margin does not have yet. " +
|
||||
"Nothing was stored."}
|
||||
</p>
|
||||
|
||||
<div className="imap-actions">
|
||||
<Button variant="primary" onClick={back}>
|
||||
Try another address
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="welcome-privacy">{WORKS_WITH}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers, in two columns, and the certificate question when there is one.
|
||||
*
|
||||
* One sheet rather than two, because the certificate is a thing one of these servers presented and
|
||||
* going back from it means going back to whoever asked. Everything the sheet says about why it is
|
||||
* open comes from the report: which leg refused, what that server said, and what to do about it.
|
||||
*/
|
||||
export function ConnectMailServers() {
|
||||
const phase = useImapConnect((s) => s.phase);
|
||||
const config = useImapConnect((s) => s.config);
|
||||
const report = useImapConnect((s) => s.report);
|
||||
const cert = useImapConnect((s) => s.cert);
|
||||
const email = useImapConnect((s) => s.email);
|
||||
const error = useImapConnect((s) => s.error);
|
||||
const work = useImapConnect((s) => s.work);
|
||||
const discovery = useImapConnect((s) => s.discovery);
|
||||
const password = useImapConnect((s) => s.password);
|
||||
const setPassword = useImapConnect((s) => s.setPassword);
|
||||
const smtpPassword = useImapConnect((s) => s.smtpPassword);
|
||||
const setSmtpPassword = useImapConnect((s) => s.setSmtpPassword);
|
||||
const setServer = useImapConnect((s) => s.setServer);
|
||||
const apply = useImapConnect((s) => s.apply);
|
||||
const trustCert = useImapConnect((s) => s.trustCert);
|
||||
const back = useImapConnect((s) => s.back);
|
||||
|
||||
const open = phase === "manual" || phase === "cert";
|
||||
if (!open || !config) return null;
|
||||
|
||||
if (phase === "cert" && cert) {
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
title="The server's certificate"
|
||||
onClose={back}
|
||||
foot={
|
||||
<>
|
||||
<Button onClick={back}>Go back</Button>
|
||||
<Button variant="danger" disabled={work !== "idle"} onClick={() => void trustCert()}>
|
||||
Trust this certificate
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="imap-lead">
|
||||
{cert.host} presented a certificate, and {wrongWith(cert.reason, cert.expiresMs)}
|
||||
</p>
|
||||
|
||||
<dl className="imap-facts">
|
||||
<Fact label="Fingerprint" value={cert.fingerprint} wrap />
|
||||
<Fact label="Subject" value={cert.subject} />
|
||||
<Fact label="Issuer" value={cert.issuer} />
|
||||
<Fact label="Expires" value={certDay.format(cert.expiresMs)} />
|
||||
</dl>
|
||||
|
||||
<p className="imap-note">
|
||||
Trusting it means Margin accepts this exact certificate on {cert.host}, port {cert.port},
|
||||
from now on, and nothing else. If the server is yours, the fingerprint above is the one it
|
||||
prints, and comparing them is the whole check. If it is not yours, an unexpected
|
||||
certificate is what somebody standing in the middle looks like, and going back costs
|
||||
nothing. A bridge running on this machine never raises this question, so this is a server
|
||||
out on the network.
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const advice = adviceFor(config, report);
|
||||
const leg = report?.failed === "smtp" ? config.smtp : config.imap;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
title="Servers"
|
||||
size="wide"
|
||||
onClose={back}
|
||||
foot={
|
||||
<>
|
||||
<Button onClick={back}>Back</Button>
|
||||
<Button variant="primary" disabled={work !== "idle"} onClick={() => void apply()}>
|
||||
{work === "idle" ? "Test and add" : work === "checking" ? "Testing" : "Adding"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{report && !report.ok ? (
|
||||
<Banner icon={icons.SPAM}>
|
||||
{report.failed === "smtp"
|
||||
? `Your mail came through. Sending, through ${leg.host}, did not.`
|
||||
: `Signing in to ${leg.host} did not work.`}
|
||||
</Banner>
|
||||
) : discovery === "empty" ? (
|
||||
<Banner icon={icons.SPAM}>
|
||||
Nobody publishes settings for {domainOf(email) || "this address"}, so they have to come
|
||||
from you. Your provider calls them IMAP and SMTP settings.
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
{report?.message && !report.ok ? <p className="imap-said">{report.message}</p> : null}
|
||||
{advice ? <p className="imap-advice">{advice}</p> : null}
|
||||
{error ? <p className="imap-said">{error}</p> : null}
|
||||
|
||||
<form
|
||||
className="imap-legs"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void apply();
|
||||
}}
|
||||
>
|
||||
<Leg
|
||||
leg="imap"
|
||||
title="Incoming mail (IMAP)"
|
||||
server={config.imap}
|
||||
onChange={(patch) => setServer("imap", patch)}
|
||||
password={{ value: password, onChange: setPassword }}
|
||||
autoFocus
|
||||
/>
|
||||
<Leg
|
||||
leg="smtp"
|
||||
title="Outgoing mail (SMTP)"
|
||||
server={config.smtp}
|
||||
onChange={(patch) => setServer("smtp", patch)}
|
||||
password={{ value: smtpPassword, onChange: setSmtpPassword }}
|
||||
/>
|
||||
{/* The sheet's own button sits in the foot, outside this form. This is what Enter presses,
|
||||
so the flow can be finished without reaching for the mouse. */}
|
||||
<button type="submit" className="imap-enter" tabIndex={-1} aria-hidden="true" />
|
||||
</form>
|
||||
|
||||
<p className="imap-note">
|
||||
Leave the outgoing username and password empty and the incoming ones are used. Some
|
||||
gateways want no credentials at all, and this is where that is said.
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const SECURITIES = [
|
||||
{ id: "plain", label: "None" },
|
||||
{ id: "start-tls", label: "STARTTLS" },
|
||||
{ id: "tls", label: "TLS" },
|
||||
];
|
||||
|
||||
interface LegProps {
|
||||
leg: "imap" | "smtp";
|
||||
title: string;
|
||||
server: ServerConfig;
|
||||
onChange: (patch: Partial<ServerConfig>) => void;
|
||||
/** Only the outgoing half has a password of its own, and only because it is optional. */
|
||||
password?: { value: string; onChange: (value: string) => void };
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
/** One half of the account: a host, a port, how the socket is protected, and who logs in. */
|
||||
function Leg({ leg, title, server, onChange, password, autoFocus }: LegProps) {
|
||||
return (
|
||||
<section className="imap-leg">
|
||||
<h3 className="imap-leg-title">{title}</h3>
|
||||
|
||||
<Field
|
||||
label="Server"
|
||||
value={server.host}
|
||||
onChange={(host) => onChange({ host })}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Port"
|
||||
value={server.port === 0 ? "" : String(server.port)}
|
||||
onChange={(value) => onChange({ port: Number(value.replace(/\D/g, "").slice(0, 5)) })}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Security</span>
|
||||
<Segment
|
||||
options={SECURITIES}
|
||||
value={server.security}
|
||||
label={`${title} security`}
|
||||
onChange={(id) => onChange({ security: id as Security })}
|
||||
/>
|
||||
{server.security === "plain" ? (
|
||||
<span className="field-hint" data-tone="error">
|
||||
Nothing on this connection is encrypted, including the password.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Username"
|
||||
value={server.username}
|
||||
onChange={(username) => onChange({ username })}
|
||||
placeholder={leg === "smtp" ? "Same as incoming" : undefined}
|
||||
/>
|
||||
|
||||
{password ? (
|
||||
<Field
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password.value}
|
||||
onChange={password.onChange}
|
||||
placeholder="Same as incoming"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A name and a value, for the four facts on a certificate and the two on a configuration. */
|
||||
function Fact({ label, value, wrap }: { label: string; value: ReactNode; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="imap-fact">
|
||||
<dt>{label}</dt>
|
||||
<dd data-wrap={wrap ? "" : undefined}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A certificate's expiry is the one date in this app that needs its year, because it is a fact
|
||||
* about a document rather than about a message and "18 Jun" cannot be checked against anything. */
|
||||
const certDay = new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const SECURITY_WORDS: Record<Security, string> = {
|
||||
plain: "no encryption",
|
||||
"start-tls": "STARTTLS",
|
||||
tls: "TLS",
|
||||
};
|
||||
|
||||
/** One server on one line: where it is, which port, and how it is protected. */
|
||||
export function serverLine(server: ServerConfig): string {
|
||||
return `${server.host}, port ${server.port}, ${SECURITY_WORDS[server.security]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same line with the login on the end of it, for the panel that has nowhere else to put one.
|
||||
* Only when it is not the address, because repeating the address back is not a fact about a server.
|
||||
*/
|
||||
function serverLineAs(server: ServerConfig, email: string): string {
|
||||
const line = serverLine(server);
|
||||
return server.username && server.username !== email.trim()
|
||||
? `${line}, as ${server.username}`
|
||||
: line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the settings came from, said before the password is typed into them, which is a different
|
||||
* promise in each of the six cases.
|
||||
*/
|
||||
function provenance(config: MailConfig, email: string, discovery: string): string {
|
||||
const domain = domainOf(email) || "this address";
|
||||
const name = providerName(config, email);
|
||||
if (discovery === "skipped") {
|
||||
return (
|
||||
"The servers are yours to type, with the usual names filled in as a start. Your provider " +
|
||||
"calls them IMAP and SMTP settings."
|
||||
);
|
||||
}
|
||||
if (discovery === "empty") {
|
||||
return (
|
||||
`Nobody publishes settings for ${domain} and no server answered at the usual names, so they ` +
|
||||
"have to come from you. Your provider calls them IMAP and SMTP settings."
|
||||
);
|
||||
}
|
||||
switch (config.source) {
|
||||
case "autoconfig":
|
||||
return `${name} publishes its own settings, so the servers are already known.`;
|
||||
case "ispdb":
|
||||
return (
|
||||
`${name}'s settings are in the public directory of mail providers that Thunderbird keeps, ` +
|
||||
"so the servers are already known."
|
||||
);
|
||||
case "mx":
|
||||
return name === domain
|
||||
? `The servers were worked out from where ${domain}'s mail is delivered.`
|
||||
: `${domain}'s mail is delivered to ${name}, which publishes its settings, so the servers are already known.`;
|
||||
case "probe":
|
||||
return (
|
||||
`Nobody publishes settings for ${domain}, so Margin tried the usual server names and ` +
|
||||
`${config.imap.host} answered. That is a guess rather than a fact: check the servers ` +
|
||||
"below before you type a password into them."
|
||||
);
|
||||
default:
|
||||
return "These are the servers you typed.";
|
||||
}
|
||||
}
|
||||
|
||||
/** What is wrong with a certificate, in the words the reason is worth translating into. */
|
||||
function wrongWith(reason: string, expiresMs: number): string {
|
||||
if (reason === "expired") {
|
||||
return `it expired on ${certDay.format(expiresMs)}, so nothing vouches for it any more.`;
|
||||
}
|
||||
if (reason === "unknown-issuer") {
|
||||
return "it was issued by somebody this machine has never heard of, so nothing independent vouches for it.";
|
||||
}
|
||||
if (reason === "self-signed") {
|
||||
return "it signed its own certificate, so the only thing saying this server is who it claims to be is the server.";
|
||||
}
|
||||
return "nothing independent vouches for it.";
|
||||
}
|
||||
|
||||
export default ConnectMail;
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, GroupHead, Icon, Popover, Toggle, icons } from "../ui";
|
||||
import { registerCommands } from "../keys/commands";
|
||||
import { useContacts } from "../store/useContacts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useStage } from "../store/useStage";
|
||||
import type { ContactCard as Card, ContactPatch, Destination } from "../ipc";
|
||||
import { displayName, fileSize, isBrand, rowTime } from "./format";
|
||||
import "./contacts.css";
|
||||
|
||||
// The contact card, mounted once at the top of the tree and hanging off whatever asked for it.
|
||||
//
|
||||
// It is one host rather than a card per name because there is only ever one open, and because a
|
||||
// popover positioned from a rect does not need to live inside the thing it points at. Who asked is
|
||||
// in `useContacts`, which is what lets a name in the list, in the pane or on a Feed card all reach
|
||||
// the same card without any of them knowing this file exists.
|
||||
|
||||
/** The four boxes, in the order the Screener offers them. Screening out is the block. */
|
||||
const DESTINATIONS: [Destination, string][] = [
|
||||
["inbox", "Inbox"],
|
||||
["feed", "Feed"],
|
||||
["paper-trail", "Paper Trail"],
|
||||
["screened-out", "Screened out"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Where a sender's mail delivers, and the one control this card exists for.
|
||||
*
|
||||
* A native select under the app's own chrome: the four boxes are a closed set and a platform picker
|
||||
* is what a closed set is, but the platform's border is not this app's border, so `appearance` is
|
||||
* dropped and the chevron is drawn from the icon set like every other one.
|
||||
*/
|
||||
export function DeliversTo({
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: Destination;
|
||||
label: string;
|
||||
onChange: (destination: Destination) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="contact-picker">
|
||||
<select
|
||||
className="contact-pick"
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as Destination)}
|
||||
>
|
||||
{DESTINATIONS.map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon d={icons.CHEVRON_DOWN} size={12} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A decision has a day, not an hour. `format.ts` has the list's dates and this is not one. */
|
||||
const decidedOn = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
const domainOf = (address: string): string => address.slice(address.indexOf("@") + 1);
|
||||
|
||||
/** "In, on 12 Aug", or that nobody has decided yet, which is what an empty date means. */
|
||||
function screenedLine(card: Card): string {
|
||||
if (card.screenedAtMs === null) return "Not yet";
|
||||
const side = card.destination === "screened-out" ? "Out" : "In";
|
||||
return `${side}, on ${decidedOn.format(card.screenedAtMs)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element the card hangs off when the keyboard asked for it rather than a pointer. The sender's
|
||||
* name in the open thread first, because that is where the mockup hangs it, then the focused row,
|
||||
* which is where the sender is when nothing is open.
|
||||
*/
|
||||
function anchorFor(address: string): HTMLElement | null {
|
||||
const names = [...document.querySelectorAll<HTMLElement>(".msg-name")];
|
||||
const inThread = names.reverse().find((el) => (el.textContent ?? "").includes(address));
|
||||
if (inThread) return inThread;
|
||||
return document.querySelector<HTMLElement>(".row[data-selected] .row-sender");
|
||||
}
|
||||
|
||||
function Note({ card }: { card: Card }) {
|
||||
const [draft, setDraft] = useState(card.note ?? "");
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
// While somebody is typing the field is theirs; the rest of the time it says what is stored, so
|
||||
// a note written on another device shows up without the card having to be closed.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(card.note ?? "");
|
||||
}, [card.note, editing]);
|
||||
|
||||
const save = () => {
|
||||
setEditing(false);
|
||||
if (draft === (card.note ?? "")) return;
|
||||
void useContacts.getState().save(card.person.address, card.accountId, { note: draft });
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="contact-note"
|
||||
rows={2}
|
||||
value={draft}
|
||||
placeholder="Add a note"
|
||||
aria-label="Note"
|
||||
onFocus={() => setEditing(true)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={save}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactCardBody({ card }: { card: Card }) {
|
||||
const name = displayName(card.person);
|
||||
const save = useContacts((s) => s.save);
|
||||
const hide = useContacts((s) => s.hide);
|
||||
const unsubscribe = useContacts((s) => s.unsubscribe);
|
||||
const unsubPhase = useContacts((s) => s.unsubscribePhase[card.person.address] ?? "idle");
|
||||
const patch = (change: ContactPatch) => void save(card.person.address, card.accountId, change);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="popover-head">
|
||||
<Avatar name={name} address={card.person.address} brand={isBrand(card.person)} size="lg" />
|
||||
<div className="contact-who">
|
||||
<div className="popover-name">{name}</div>
|
||||
<div className="popover-sub">{card.person.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="popover-rows">
|
||||
<div className="popover-row">
|
||||
<span className="lab">Delivers to</span>
|
||||
<span className="val">
|
||||
<DeliversTo
|
||||
value={card.destination}
|
||||
label="Delivers to"
|
||||
onChange={(destination) => patch({ destination })}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* A consumer domain is not a group of any kind, so the toggle is not offered rather than
|
||||
offered and then refused. */}
|
||||
{card.domainRuleAllowed ? (
|
||||
<div className="contact-toggle">
|
||||
<Toggle
|
||||
checked={card.domainRule}
|
||||
onChange={(on) => patch({ domainRule: on })}
|
||||
label={`Everyone at ${domainOf(card.person.address)}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="contact-toggle">
|
||||
<Toggle checked={card.notify} onChange={(on) => patch({ notify: on })} label="Notify" />
|
||||
</div>
|
||||
|
||||
<div className="popover-row">
|
||||
<span className="lab">Screened</span>
|
||||
<span className="val">{screenedLine(card)}</span>
|
||||
</div>
|
||||
|
||||
<div className="popover-row contact-note-row">
|
||||
<span className="lab">Note</span>
|
||||
<Note card={card} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.recentThreads.length > 0 ? (
|
||||
<section className="contact-section">
|
||||
<GroupHead>Recent threads</GroupHead>
|
||||
{card.recentThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.key}
|
||||
type="button"
|
||||
className="contact-line"
|
||||
onClick={() => {
|
||||
hide();
|
||||
useStage.getState().close();
|
||||
void useMail.getState().open(thread.key);
|
||||
}}
|
||||
>
|
||||
<span className="contact-line-name">{thread.subject}</span>
|
||||
<span className="contact-line-side">{rowTime(thread.dateMs)}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{card.files.length > 0 ? (
|
||||
<section className="contact-section">
|
||||
<GroupHead>Files</GroupHead>
|
||||
{card.files.map((file) => (
|
||||
<div key={file.id} className="contact-line">
|
||||
<span className="contact-line-name">{file.filename}</span>
|
||||
<span className="contact-line-side">{fileSize(file.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{card.unsubscribe ? (
|
||||
<div className="contact-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="contact-unsub"
|
||||
data-phase={unsubPhase}
|
||||
disabled={unsubPhase === "unsubscribing"}
|
||||
onClick={() => void unsubscribe(card.accountId, card.person.address)}
|
||||
>
|
||||
{unsubPhase === "unsubscribing" ? "Unsubscribing" : "Unsubscribe"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactCards() {
|
||||
const anchor = useContacts((s) => s.anchor);
|
||||
const address = useContacts((s) => s.address);
|
||||
const card = useContacts((s) => s.card);
|
||||
const phase = useContacts((s) => s.phase);
|
||||
const hide = useContacts((s) => s.hide);
|
||||
|
||||
// `i` has no owner anywhere else: the card is not a screen, so the host that draws it is what
|
||||
// registers the key, for as long as it is mounted, which is always.
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands({
|
||||
"contact-card": () => {
|
||||
const { threads, focused } = useMail.getState();
|
||||
const thread = threads.find((t) => t.key === focused);
|
||||
if (!thread) return;
|
||||
const at = anchorFor(thread.from.address);
|
||||
if (!at) return;
|
||||
void useContacts.getState().show(thread.from.address, thread.accountId, at);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={address !== null && anchor !== null}
|
||||
anchor={anchor}
|
||||
onClose={hide}
|
||||
label="Contact card"
|
||||
>
|
||||
{card ? (
|
||||
<ContactCardBody card={card} />
|
||||
) : (
|
||||
<div className="contact-waiting" data-phase={phase} aria-busy={phase === "loading"}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactCards;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EmptyState, Icon, NO_AUTOFILL, Row, Toggle, icons } from "../ui";
|
||||
import { useContacts } from "../store/useContacts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import type { ContactCard } from "../ipc";
|
||||
import { DeliversTo } from "./ContactCards";
|
||||
import { displayName, isBrand } from "./format";
|
||||
import "./list.css";
|
||||
import "./contacts.css";
|
||||
|
||||
// The Contacts place: everyone with a decision about them, searchable, with the card's two
|
||||
// controls on the row.
|
||||
//
|
||||
// The row is the mail list's row and not a second one, because a person here is read the same way a
|
||||
// thread is read in the Inbox and a second anatomy would be a second thing to learn. What is on it
|
||||
// that a thread has not is the destination and the notify switch, which are the two decisions worth
|
||||
// changing without opening anything.
|
||||
|
||||
/** A decision has a day, not an hour, and the row prints it where a thread prints its time. */
|
||||
const decidedOn = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
function ContactItem({ card }: { card: ContactCard }) {
|
||||
const name = displayName(card.person);
|
||||
const save = useContacts((s) => s.save);
|
||||
const at = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="contact-item" ref={at}>
|
||||
<Row
|
||||
sender={name}
|
||||
address={card.person.address}
|
||||
brand={isBrand(card.person)}
|
||||
time={card.screenedAtMs === null ? "" : decidedOn.format(card.screenedAtMs)}
|
||||
subject={card.person.address}
|
||||
note={card.note ?? undefined}
|
||||
onClick={() =>
|
||||
void useContacts.getState().show(card.person.address, card.accountId, at.current)
|
||||
}
|
||||
/>
|
||||
<div className="contact-controls">
|
||||
<DeliversTo
|
||||
value={card.destination}
|
||||
label={`Delivers to, for ${name}`}
|
||||
onChange={(destination) =>
|
||||
void save(card.person.address, card.accountId, { destination })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
checked={card.notify}
|
||||
onChange={(on) => void save(card.person.address, card.accountId, { notify: on })}
|
||||
label="Notify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Contacts() {
|
||||
const people = useContacts((s) => s.people);
|
||||
const query = useContacts((s) => s.query);
|
||||
const phase = useContacts((s) => s.listPhase);
|
||||
const setQuery = useContacts((s) => s.setQuery);
|
||||
const load = useContacts((s) => s.load);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [accountId, load]);
|
||||
|
||||
return (
|
||||
<main className="stage contacts">
|
||||
<div className="list-head contacts-head">
|
||||
<h1 className="list-title">Contacts</h1>
|
||||
<label className="contacts-search">
|
||||
<Icon d={icons.SEARCH} size={14} />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search contacts"
|
||||
aria-label="Search contacts"
|
||||
{...NO_AUTOFILL}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* What was there stays there while the next answer is out, and the phase on the list is
|
||||
what says so. Emptying it between keystrokes was the flicker: a query that matched nobody
|
||||
and then somebody blinked blank on every letter in between. Before any answer at all
|
||||
there is nothing to keep, so the row's shape is drawn faintly instead. */}
|
||||
<div
|
||||
className={people && people.length > 0 ? "list contacts-list" : "list list-blank contacts-list"}
|
||||
data-phase={phase}
|
||||
aria-busy={phase === "loading"}
|
||||
>
|
||||
{phase === "error" ? (
|
||||
<p className="contacts-note">Could not load your contacts</p>
|
||||
) : people === null ? (
|
||||
<div className="contacts-waiting" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : people.length === 0 ? (
|
||||
<EmptyState>{query ? "Nobody by that name" : "Nobody yet"}</EmptyState>
|
||||
) : null}
|
||||
{people?.map((card) => (
|
||||
<ContactItem key={`${card.accountId}:${card.person.address}`} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Contacts;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { hasText } from "../store/useCompose";
|
||||
import "./editor.css";
|
||||
|
||||
/**
|
||||
* The body of a message, which is TipTap and StarterKit and nothing else.
|
||||
*
|
||||
* Paragraphs, bold, italic, links, lists, quotes and code, built the way margin builds its editor
|
||||
* in `src/editor/extensions.ts`: one configured StarterKit rather than a list of extensions
|
||||
* assembled by hand. What is deliberately absent is colour and type. A mail client that lets you
|
||||
* choose a typeface is a mail client that sends mail nobody can read, and the faces in settings are
|
||||
* the reader's choice rather than the writer's.
|
||||
*
|
||||
* There is no toolbar either. Every mark this editor can make has a key in docs/keyboard.md, the
|
||||
* keys are TipTap's own, and `src/keys/bindings.ts` declares them with a null command so the
|
||||
* dispatcher sees the frame and stands out of the way. `Cmd+K` is the one real collision in the
|
||||
* app, and inside here the link wins because the palette is one Escape away and a link is not.
|
||||
*
|
||||
* It writes HTML. Rust inlines the stylesheet and builds the plain text alternative on the way out,
|
||||
* which is why nothing here has an opinion about what the recipient's client can render.
|
||||
*/
|
||||
|
||||
interface EditorProps {
|
||||
html: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder: string;
|
||||
label: string;
|
||||
/**
|
||||
* Takes the caret when it appears, which is what `r` is for.
|
||||
*
|
||||
* At the start of the document rather than the end, because a draft opens with a signature under
|
||||
* it and nobody writes underneath their own name.
|
||||
*/
|
||||
autoFocus?: boolean;
|
||||
/** Handed the instance so the box around it can put the caret back. */
|
||||
onReady?: (editor: TiptapEditor | null) => void;
|
||||
}
|
||||
|
||||
const EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
// Mail has no headings and no rules. The subject is the heading and the hairline between
|
||||
// messages is the pane's, so both would be a mark the reader meets somewhere it means nothing.
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
||||
}),
|
||||
];
|
||||
|
||||
export function Editor({ html, onChange, placeholder, label, autoFocus, onReady }: EditorProps) {
|
||||
const emitted = useRef(html);
|
||||
const ready = useRef(onReady);
|
||||
ready.current = onReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: EXTENSIONS,
|
||||
content: html,
|
||||
autofocus: autoFocus ? "start" : false,
|
||||
editorProps: {
|
||||
attributes: { class: "editor-body", "aria-label": label },
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
emitted.current = editor.getHTML();
|
||||
onChange(emitted.current);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ready.current?.(editor ?? null);
|
||||
return () => ready.current?.(null);
|
||||
}, [editor]);
|
||||
|
||||
// Written from outside: Instant intro puts a line at the top and pressing it again takes the line
|
||||
// away. Comparing against what this editor last emitted rather than against its own document is
|
||||
// what keeps that from fighting the caret on every keystroke.
|
||||
useEffect(() => {
|
||||
if (!editor || html === emitted.current) return;
|
||||
emitted.current = html;
|
||||
editor.commands.setContent(html, { emitUpdate: false });
|
||||
}, [editor, html]);
|
||||
|
||||
return (
|
||||
<div className="editor" data-empty={hasText(html) ? undefined : ""}>
|
||||
{/* The placeholder is a sibling rather than the Placeholder extension, which is a dependency
|
||||
this app does not have and would be carrying for one line of grey text. */}
|
||||
<span className="editor-placeholder" aria-hidden="true">
|
||||
{placeholder}
|
||||
</span>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Banner, Button, EmptyState, icons } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
import { keyFor, keyLabel, type CommandId } from "../keys/bindings";
|
||||
import { readLeftOff, writeLeftOff } from "../leftOff";
|
||||
import type { ThreadSummary, ThreadView } from "../ipc";
|
||||
import { useContacts } from "../store/useContacts";
|
||||
import { useFeed } from "../store/useFeed";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { displayName, isBrand, messageTime } from "./format";
|
||||
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody";
|
||||
import * as triage from "./triage";
|
||||
import "./feed.css";
|
||||
|
||||
/**
|
||||
* The Feed: the whole stage, one column of cards, each already open.
|
||||
*
|
||||
* No read state, no counts, no weight. Time is the only order, and the one thing carried from
|
||||
* the last visit is a hairline where you stopped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The key a button prints. `cap` in format.ts prints an unmodified key as itself, which is right
|
||||
* for `v` and gives `Enter` where the button wants the arrow, so a named key takes its glyph.
|
||||
*/
|
||||
function keycapOf(command: CommandId): string | undefined {
|
||||
const combo = keyFor(command);
|
||||
if (!combo) return undefined;
|
||||
return combo.length === 1 ? combo : keyLabel(combo);
|
||||
}
|
||||
|
||||
/** How much of a body shows before the fade, read from the token layer rather than known here. */
|
||||
function clipHeight(): number {
|
||||
const held = getComputedStyle(document.documentElement).getPropertyValue("--feed-clip");
|
||||
return parseFloat(held) || 0;
|
||||
}
|
||||
|
||||
const startOfToday = (): number => {
|
||||
const day = new Date();
|
||||
day.setHours(0, 0, 0, 0);
|
||||
return day.getTime();
|
||||
};
|
||||
|
||||
export function Feed() {
|
||||
const threads = useMail((s) => s.threads);
|
||||
const phase = useMail((s) => s.phase);
|
||||
const loadMore = useMail((s) => s.loadMore);
|
||||
const views = useFeed((s) => s.views);
|
||||
const focused = useFeed((s) => s.focused);
|
||||
const marker = useFeed((s) => s.marker);
|
||||
const arrive = useFeed((s) => s.arrive);
|
||||
const step = useFeed((s) => s.step);
|
||||
const toggle = useFeed((s) => s.toggle);
|
||||
const focus = useFeed((s) => s.focus);
|
||||
|
||||
const scroller = useRef<HTMLDivElement | null>(null);
|
||||
const cards = useRef(new Map<string, HTMLElement>());
|
||||
/** The newest card on screen, which is what the hairline will mark on the next visit. */
|
||||
const top = useRef<number | null>(null);
|
||||
|
||||
const keys = useMemo(() => threads.map((t) => t.key), [threads]);
|
||||
|
||||
// Where the last visit ended, read once. A marker that moved while you read would be a hairline
|
||||
// that walks down the page.
|
||||
useEffect(() => {
|
||||
arrive(readLeftOff("feed"));
|
||||
}, [arrive]);
|
||||
|
||||
// The place is left when this unmounts, which is what going anywhere else does to it. It belongs
|
||||
// in the state database's `markers` table; see src/leftOff.ts.
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
if (top.current !== null) writeLeftOff("feed", top.current);
|
||||
};
|
||||
window.addEventListener("pagehide", save);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", save);
|
||||
save();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands({
|
||||
"select-next": () => step(keys, 1),
|
||||
"select-prev": () => step(keys, -1),
|
||||
"open-selection": () => {
|
||||
const key = useFeed.getState().focused;
|
||||
if (key) toggle(key);
|
||||
},
|
||||
undo: () => void triage.undo(),
|
||||
// The sender of the card the keyboard is on. The button on each card goes to the same
|
||||
// action with its own sender, because a click lands before the card takes the focus.
|
||||
unsubscribe: () => {
|
||||
const key = useFeed.getState().focused;
|
||||
const thread = useMail.getState().threads.find((t) => t.key === key);
|
||||
if (thread) void useContacts.getState().unsubscribe(thread.accountId, thread.from.address);
|
||||
},
|
||||
// Move and Save clip have bindings and buttons and no handler here yet. Rust has both
|
||||
// commands; what is missing is the picker and the selection on this side. An unregistered
|
||||
// command does nothing at all, which is what docs/keyboard.md asks for and better than a
|
||||
// verb that half happens.
|
||||
}),
|
||||
[keys, step, toggle],
|
||||
);
|
||||
|
||||
// A card's body is fetched as the column reaches it, so a Feed of two hundred newsletters is not
|
||||
// two hundred `thread_view` calls at once, each of which the mirror answers by fetching a body.
|
||||
useEffect(() => {
|
||||
const root = scroller.current;
|
||||
if (!root) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const key = (entry.target as HTMLElement).dataset.card;
|
||||
if (key) useFeed.getState().want(key);
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: "600px 0px" },
|
||||
);
|
||||
for (const el of cards.current.values()) observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [keys]);
|
||||
|
||||
// The focused card has to be on screen, or `j` walks the column from behind the fold.
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
cards.current.get(focused)?.scrollIntoView({ block: "nearest" });
|
||||
}, [focused]);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const root = scroller.current;
|
||||
if (!root) return;
|
||||
const edge = root.getBoundingClientRect().top;
|
||||
for (const key of keys) {
|
||||
const el = cards.current.get(key);
|
||||
if (el && el.getBoundingClientRect().bottom > edge + 1) {
|
||||
top.current = Number(el.dataset.at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (root.scrollTop + root.clientHeight > root.scrollHeight - 600) void loadMore();
|
||||
}, [keys, loadMore]);
|
||||
|
||||
// The topmost card before anything has scrolled, so leaving a Feed you did not scroll still
|
||||
// leaves a mark.
|
||||
useEffect(() => {
|
||||
if (top.current === null && threads.length > 0) top.current = threads[0].dateMs;
|
||||
}, [threads]);
|
||||
|
||||
const strippedToday = useMemo(() => {
|
||||
const today = startOfToday();
|
||||
return Object.values(views).filter((view) =>
|
||||
view.messages.some((m) => m.trackers.length > 0 && m.dateMs >= today),
|
||||
).length;
|
||||
}, [views]);
|
||||
|
||||
// The hairline goes above the card that was newest on screen last time, and nowhere at all when
|
||||
// that is still the newest card there is: there is nothing above it to have arrived since.
|
||||
const markerAt = useMemo(() => {
|
||||
if (marker === null) return -1;
|
||||
const at = threads.findIndex((t) => t.dateMs <= marker);
|
||||
return at > 0 ? at : -1;
|
||||
}, [threads, marker]);
|
||||
|
||||
return (
|
||||
<main className="stage">
|
||||
<div className="feed" ref={scroller} onScroll={onScroll}>
|
||||
<div className="feed-inner">
|
||||
<Banner icon={icons.SHIELD}>
|
||||
<>
|
||||
{"Images are loaded through Margin, never from the sender."}
|
||||
{strippedToday > 0 ? (
|
||||
<>
|
||||
{" Trackers stripped from "}
|
||||
<b>{`${strippedToday} item${strippedToday === 1 ? "" : "s"}`}</b>
|
||||
{" today."}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Banner>
|
||||
|
||||
{threads.length === 0 && phase !== "loading" ? (
|
||||
<EmptyState>Nothing here</EmptyState>
|
||||
) : null}
|
||||
|
||||
{threads.map((thread, at) => (
|
||||
<Fragment key={thread.key}>
|
||||
{at === markerAt ? <div className="left-off">You left off here</div> : null}
|
||||
<Card
|
||||
thread={thread}
|
||||
view={views[thread.key]}
|
||||
focused={thread.key === focused}
|
||||
onFocus={() => focus(thread.key)}
|
||||
onToggle={() => toggle(thread.key)}
|
||||
hold={(el) => {
|
||||
if (el) cards.current.set(thread.key, el);
|
||||
else cards.current.delete(thread.key);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
thread: ThreadSummary;
|
||||
view: ThreadView | undefined;
|
||||
focused: boolean;
|
||||
onFocus: () => void;
|
||||
onToggle: () => void;
|
||||
hold: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
function Card({ thread, view, focused, onFocus, onToggle, hold }: CardProps) {
|
||||
const expanded = useFeed((s) => s.expanded.includes(thread.key));
|
||||
const phase = useFeed((s) => s.phase[thread.key]);
|
||||
const retry = useFeed((s) => s.retry);
|
||||
const body = useRef<HTMLDivElement | null>(null);
|
||||
const [clipped, setClipped] = useState(false);
|
||||
|
||||
// How tall the body really is, and so whether there is more of it than the clip shows. Measured
|
||||
// inside the frame rather than off it, because the frame is at least its own default height
|
||||
// whatever the message in it comes to, and it arrives a beat after the card does.
|
||||
useEffect(() => {
|
||||
const el = body.current;
|
||||
const frame = el?.querySelector("iframe");
|
||||
if (!el || !frame) return;
|
||||
const measure = () => {
|
||||
const content = frame.contentDocument?.body?.scrollHeight ?? 0;
|
||||
if (content <= 0) return;
|
||||
el.style.setProperty("--feed-body-h", `${content}px`);
|
||||
// While it is open there is no clip to overflow, so the answer from when it was closed
|
||||
// stands: this is what keeps See less from turning back into Read more.
|
||||
if (!expanded) setClipped(content > clipHeight());
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(frame);
|
||||
// A body shorter than the frame's default never changes the frame's box, so the observer would
|
||||
// never fire for it: the load is the only moment those cards can be measured at.
|
||||
frame.addEventListener("load", measure);
|
||||
measure();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
frame.removeEventListener("load", measure);
|
||||
};
|
||||
}, [view, expanded]);
|
||||
|
||||
const message = view?.messages.at(-1);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="feed-card"
|
||||
ref={hold}
|
||||
data-card={thread.key}
|
||||
data-at={thread.dateMs}
|
||||
data-selected={focused ? "" : undefined}
|
||||
onClick={onFocus}
|
||||
>
|
||||
<div className="feed-head">
|
||||
<Avatar
|
||||
name={displayName(thread.from)}
|
||||
address={thread.from.address}
|
||||
brand={isBrand(thread.from)}
|
||||
/>
|
||||
<div className="msg-who">
|
||||
<div className="msg-name">
|
||||
{displayName(thread.from)}
|
||||
<span className="addr">{thread.from.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="msg-time">{messageTime(thread.dateMs)}</div>
|
||||
</div>
|
||||
|
||||
<h2 className="feed-title">{thread.subject}</h2>
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
className="feed-body"
|
||||
ref={body}
|
||||
data-open={expanded ? "" : undefined}
|
||||
data-paper={message.surface === "paper" ? "" : undefined}
|
||||
>
|
||||
<MessageBody html={message.html} surface={message.surface} />
|
||||
{clipped && !expanded ? <div className="feed-fade" /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="feed-wait">
|
||||
{phase === "error" ? (
|
||||
<BodyMissing onRetry={() => retry(thread.key)} />
|
||||
) : (
|
||||
<BodySkeleton />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feed-foot">
|
||||
<Button keycap={keycapOf("open-selection")} onClick={onToggle}>
|
||||
{expanded ? "See less" : "Read more"}
|
||||
</Button>
|
||||
<Button keycap={keycapOf("save-clip")} onClick={() => runCommand("save-clip")}>
|
||||
Save clip
|
||||
</Button>
|
||||
<span className="feed-gap" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
keycap={keycapOf("unsubscribe")}
|
||||
onClick={() =>
|
||||
void useContacts.getState().unsubscribe(thread.accountId, thread.from.address)
|
||||
}
|
||||
>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
<Button variant="ghost" keycap={keycapOf("move")} onClick={() => runCommand("move")}>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default Feed;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Button, EmptyState, Icon, icons } from "../ui";
|
||||
import type { FileCard, Person } from "../ipc";
|
||||
import { CATEGORIES, useLibrary } from "../store/useLibrary";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { openThread } from "./Clips";
|
||||
import { displayName, fileKind, fileSize } from "./format";
|
||||
import "./list.css";
|
||||
import "./library.css";
|
||||
|
||||
/**
|
||||
* The All files place: every attachment in the mirror as a card, newest first.
|
||||
*
|
||||
* A grid rather than a list, because what you are looking for here is a document you half
|
||||
* remember, and a filename and a type mark are what you recognise it by. Nothing is fetched: the
|
||||
* cards are the local index, and the file itself is downloaded when the thread it is in is opened.
|
||||
*/
|
||||
const fileDate = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
/** Everyone who has sent a file, for the second filter. Drawn from the cards rather than asked for. */
|
||||
function sendersOf(files: FileCard[]): Person[] {
|
||||
const seen = new Map<string, Person>();
|
||||
for (const card of files) {
|
||||
if (!seen.has(card.sender.address)) seen.set(card.sender.address, card.sender);
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => displayName(a).localeCompare(displayName(b)));
|
||||
}
|
||||
|
||||
export function Files() {
|
||||
const files = useLibrary((s) => s.files);
|
||||
const phase = useLibrary((s) => s.filesPhase);
|
||||
const category = useLibrary((s) => s.category);
|
||||
const sender = useLibrary((s) => s.sender);
|
||||
const load = useLibrary((s) => s.loadFiles);
|
||||
const setCategory = useLibrary((s) => s.setCategory);
|
||||
const setSender = useLibrary((s) => s.setSender);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [accountId, load]);
|
||||
|
||||
// The sender list is whoever is in the answer, which means it narrows with the type filter and
|
||||
// never offers a name that would give back nothing.
|
||||
const senders = useMemo(() => sendersOf(files), [files]);
|
||||
|
||||
return (
|
||||
<main className="stage library">
|
||||
<div className="list-head">
|
||||
<h1 className="list-title">All files</h1>
|
||||
</div>
|
||||
|
||||
<div className="files-filters">
|
||||
{CATEGORIES.map((option) => (
|
||||
<Button
|
||||
key={option.id || "all"}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
active={category === option.id}
|
||||
onClick={() => setCategory(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="files-gap" />
|
||||
<label className="files-sender">
|
||||
From
|
||||
{/* The platform's own dropdown chrome is dropped and the app's is put back, so the
|
||||
chevron beside it is the same drawing as every other chevron here. */}
|
||||
<span className="files-picker">
|
||||
<select value={sender} onChange={(e) => setSender(e.target.value)} aria-label="From">
|
||||
<option value="">Anyone</option>
|
||||
{senders.map((person) => (
|
||||
<option key={person.address} value={person.address}>
|
||||
{displayName(person)}
|
||||
</option>
|
||||
))}
|
||||
{/* The chosen sender survives a type filter that has nothing of theirs in it, or the
|
||||
control would silently reset itself to Anyone and show more than was asked for. */}
|
||||
{sender && !senders.some((p) => p.address === sender) ? (
|
||||
<option value={sender}>{sender}</option>
|
||||
) : null}
|
||||
</select>
|
||||
<Icon d={icons.CHEVRON_DOWN} size={12} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="list list-blank">
|
||||
{phase === "loading" ? null : <EmptyState>Nothing here</EmptyState>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="list files-grid">
|
||||
{files.map((card) => (
|
||||
<button
|
||||
type="button"
|
||||
className="file-card"
|
||||
key={card.attachment.id}
|
||||
data-category={card.category}
|
||||
onClick={() => openThread(card.threadKey)}
|
||||
>
|
||||
<span className="file-mark">
|
||||
{fileKind(card.attachment.filename, card.attachment.mimeType)}
|
||||
</span>
|
||||
<span className="file-name">{card.attachment.filename}</span>
|
||||
<span className="file-meta">
|
||||
{`${displayName(card.sender)} · ${fileSize(card.attachment.size)}`}
|
||||
</span>
|
||||
<span className="file-meta">
|
||||
{`${card.subject} · ${fileDate.format(card.dateMs)}`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Files;
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Button, EmptyState, Icon, icons, Key } from "../ui";
|
||||
import { registerCommands } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import { threadOpened, threadView } from "../api/threads";
|
||||
import type { ThreadSummary, ThreadView } from "../ipc";
|
||||
import { useCompose } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { useStage } from "../store/useStage";
|
||||
import { cap, displayName, isBrand, messageTime } from "./format";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
import "./focus.css";
|
||||
|
||||
/**
|
||||
* Focus & Reply: the whole stage, one item per thread on the Reply later pile.
|
||||
*
|
||||
* The page is the pile read one at a time. Each item is the latest message on the left and a reply
|
||||
* box on the right, in one bordered card, and the item the keyboard is on takes a ring. `Tab` moves
|
||||
* on and the thread stays where it was, which is the point: skipping is the ordinary outcome and
|
||||
* costs nothing.
|
||||
*
|
||||
* Send goes down the same pipeline the compose card and the thread's reply box use: the draft is
|
||||
* handed to `useCompose`, queued, and held for the undo delay while the toast counts it down. The
|
||||
* item collapses to one line the moment it is queued, so the page shortens as it is worked through,
|
||||
* and taking the send back brings the item and what was typed in it straight back.
|
||||
*
|
||||
* The box here is a textarea rather than the TipTap editor the other two composers use, which is
|
||||
* deliberate: this page is a pile answered one line at a time, and a formatting toolbar's worth of
|
||||
* document model is not what "Wednesday at four suits us" needs.
|
||||
*/
|
||||
export function FocusReply() {
|
||||
const threads = usePiles((s) => s.threads);
|
||||
const load = usePiles((s) => s.load);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const close = useStage((s) => s.close);
|
||||
|
||||
const items = threads["reply-later"];
|
||||
const [at, setAt] = useState(0);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
/** The threads that have been answered on this visit, and who they went to. */
|
||||
const [sent, setSent] = useState<Record<string, string>>({});
|
||||
const [views, setViews] = useState<Record<string, ThreadView>>({});
|
||||
const boxes = useRef(new Map<string, HTMLTextAreaElement>());
|
||||
/** Which box the caret is in, which is what decides whose reply `Cmd+Enter` sends. */
|
||||
const [typing, setTyping] = useState<string | null>(null);
|
||||
const replyKey = useCompose((s) => s.replyKey);
|
||||
const holding = useCompose((s) => s.holding);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [accountId, load]);
|
||||
|
||||
// The pile is a handful of threads, so the messages are asked for at once rather than as the
|
||||
// page reaches them: this is a page you work down, not a feed you scroll.
|
||||
useEffect(() => {
|
||||
for (const thread of items) {
|
||||
if (views[thread.key]) continue;
|
||||
void threadView(thread.key)
|
||||
.then((view) => setViews((was) => (was[view.key] ? was : { ...was, [view.key]: view })))
|
||||
.catch(() => {
|
||||
// A thread whose body will not come back is an item with its subject and its reply box,
|
||||
// which is still the thing you came here to do.
|
||||
});
|
||||
}
|
||||
}, [items, views]);
|
||||
|
||||
// `Tab` is this page's and nothing else's while it is up, and the box the caret is in takes the
|
||||
// editor's frame on top of it so that `Cmd+Enter` resolves to Send at all. `Tab` still works
|
||||
// through the box's own handler, which is what the dispatcher standing out of a text field's way
|
||||
// leaves room for.
|
||||
useKeyContext("focus");
|
||||
useKeyContext("editor", typing !== null);
|
||||
|
||||
const left = useMemo(() => items.filter((t) => !sent[t.key]).length, [items, sent]);
|
||||
|
||||
// Every item's latest message is on the page, but the one with the ring and the caret is the one
|
||||
// being read, so it is the one marked seen, the way the pane marks what it shows. Keyed on the
|
||||
// thread rather than on the list, because the pile is asked for again on every invalidation and
|
||||
// the same item is not opened twice by that.
|
||||
const activeKey = items[at]?.key;
|
||||
useEffect(() => {
|
||||
if (!activeKey) return;
|
||||
void threadOpened(activeKey).catch(() => {
|
||||
// Nothing on this page shows read state, so there is nothing to put back and nothing to say.
|
||||
});
|
||||
}, [activeKey]);
|
||||
|
||||
const step = (delta: number) =>
|
||||
setAt((was) => Math.min(Math.max(was + delta, 0), Math.max(items.length - 1, 0)));
|
||||
|
||||
/** One item's reply, down the pipeline the other two composers use. */
|
||||
const post = (thread: ThreadSummary, now: boolean) => {
|
||||
const view = views[thread.key];
|
||||
const last = view?.messages.at(-1);
|
||||
const body = (drafts[thread.key] ?? "").trim();
|
||||
if (!view || !last || !body) return;
|
||||
|
||||
const compose = useCompose.getState();
|
||||
compose.answer(view, last, "reply", useSettings.getState().settings?.replyAllDefault ?? false);
|
||||
compose.edit("reply", {
|
||||
bodyHtml: body
|
||||
.split(/\n{2,}/)
|
||||
.map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
|
||||
.join(""),
|
||||
});
|
||||
const to = compose.reply?.draft.to[0];
|
||||
void compose.post("reply", now);
|
||||
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
|
||||
step(1);
|
||||
};
|
||||
|
||||
const latest = useRef(post);
|
||||
latest.current = post;
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands({
|
||||
"focus-next": () => step(1),
|
||||
"focus-prev": () => step(-1),
|
||||
}),
|
||||
[items.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typing === null) return;
|
||||
const thread = items.find((t) => t.key === typing);
|
||||
if (!thread) return;
|
||||
return registerCommands({
|
||||
send: () => latest.current(thread, false),
|
||||
"send-now": () => latest.current(thread, true),
|
||||
});
|
||||
}, [typing, items]);
|
||||
|
||||
// `z` inside the delay means the send, here as well as in the pane. This page is a stage and the
|
||||
// reading pane is not mounted behind it, so the compose card's copy of this registration is not
|
||||
// on screen to be reached.
|
||||
useEffect(() => {
|
||||
if (!holding) return;
|
||||
return registerCommands({ undo: () => void useCompose.getState().undoSend() });
|
||||
}, [holding]);
|
||||
|
||||
// A send that was taken back puts its item straight back, because `undoSend` reopens the draft on
|
||||
// the thread it came from and that is the only signal this page needs.
|
||||
useEffect(() => {
|
||||
if (!replyKey || !sent[replyKey]) return;
|
||||
setSent((was) => {
|
||||
const next = { ...was };
|
||||
delete next[replyKey];
|
||||
return next;
|
||||
});
|
||||
}, [replyKey, sent]);
|
||||
|
||||
// The box the keyboard is on takes the caret, so the page is typed into rather than clicked
|
||||
// into. Tab out of a box is handled by the box itself: the dispatcher never takes a key from a
|
||||
// text field, and this screen's field is the one that wants to give this one up.
|
||||
useEffect(() => {
|
||||
const key = items[at]?.key;
|
||||
if (key) boxes.current.get(key)?.focus();
|
||||
}, [at, items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<main className="stage focus">
|
||||
<div className="focus-inner">
|
||||
<Head left={0} />
|
||||
<div className="focus-blank">
|
||||
<EmptyState>Nothing in Reply later</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="stage focus">
|
||||
<div className="focus-inner">
|
||||
<Head left={left} />
|
||||
|
||||
{items.map((thread, index) =>
|
||||
sent[thread.key] ? (
|
||||
<div className="focus-sent" key={thread.key}>
|
||||
<Icon d={icons.CHECK} size={14} />
|
||||
<span>
|
||||
{"Sent to "}
|
||||
<b>{sent[thread.key]}</b>
|
||||
{` · ${thread.subject}`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Item
|
||||
key={thread.key}
|
||||
thread={thread}
|
||||
view={views[thread.key]}
|
||||
active={index === at}
|
||||
draft={drafts[thread.key] ?? ""}
|
||||
onDraft={(body) => setDrafts((was) => ({ ...was, [thread.key]: body }))}
|
||||
onFocus={() => setAt(index)}
|
||||
onTyping={(on) => setTyping(on ? thread.key : null)}
|
||||
onSend={() => post(thread, false)}
|
||||
onSkip={() => step(1)}
|
||||
onStep={step}
|
||||
register={(el) => {
|
||||
if (el) boxes.current.set(thread.key, el);
|
||||
else boxes.current.delete(thread.key);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="focus-foot">
|
||||
<Button variant="ghost" onClick={close} keycap="⎋">
|
||||
Back to Reply later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** The title, the sentence, and the three keys this page answers to, right aligned under them. */
|
||||
function Head({ left }: { left: number }) {
|
||||
return (
|
||||
<div className="focus-head">
|
||||
<h1 className="focus-title">Focus & Reply</h1>
|
||||
<p className="focus-lede">
|
||||
Every thread in Reply later, each with its own reply box. Send, or move on to the next.
|
||||
</p>
|
||||
<p className="focus-hint">
|
||||
<span>{`${left} left`}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Key size="sm">{cap("focus-next") ?? "Tab"}</Key>
|
||||
<span>next</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Key size="sm">{cap("send") ?? "⌘↩"}</Key>
|
||||
<span>send</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Key size="sm">⎋</Key>
|
||||
<span>back</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
thread: ThreadSummary;
|
||||
view: ThreadView | undefined;
|
||||
active: boolean;
|
||||
draft: string;
|
||||
onDraft: (body: string) => void;
|
||||
onFocus: () => void;
|
||||
/** Whether the caret is in this box, which is what decides whose reply `Cmd+Enter` sends. */
|
||||
onTyping: (on: boolean) => void;
|
||||
onSend: () => void;
|
||||
onSkip: () => void;
|
||||
onStep: (delta: number) => void;
|
||||
register: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
function Item({
|
||||
thread,
|
||||
view,
|
||||
active,
|
||||
draft,
|
||||
onDraft,
|
||||
onFocus,
|
||||
onTyping,
|
||||
onSend,
|
||||
onSkip,
|
||||
onStep,
|
||||
register,
|
||||
}: ItemProps) {
|
||||
const message = view?.messages.at(-1);
|
||||
const from = message?.from ?? thread.from;
|
||||
const name = displayName(from);
|
||||
|
||||
return (
|
||||
<article className="focus-item" data-active={active ? "" : undefined} onClick={onFocus}>
|
||||
<div className="focus-message">
|
||||
<h2 className="focus-subject">{thread.subject}</h2>
|
||||
<div className="focus-from">
|
||||
<Avatar name={name} address={from.address} brand={isBrand(from)} />
|
||||
<div className="focus-who">
|
||||
<div className="focus-name">
|
||||
{name}
|
||||
<span className="addr">{from.address}</span>
|
||||
</div>
|
||||
<div className="focus-to">to you</div>
|
||||
</div>
|
||||
<div className="focus-time">{messageTime(message?.dateMs ?? thread.dateMs)}</div>
|
||||
</div>
|
||||
<div className="focus-body">
|
||||
{message ? (
|
||||
<MessageBody html={message.html} surface={message.surface} />
|
||||
) : (
|
||||
<p className="focus-snippet">{thread.snippet}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="focus-reply">
|
||||
<div className="focus-reply-head">
|
||||
{"Reply to "}
|
||||
<b>{name}</b>
|
||||
</div>
|
||||
<textarea
|
||||
className="focus-box"
|
||||
ref={register}
|
||||
value={draft}
|
||||
placeholder="Write a reply"
|
||||
aria-label={`Reply to ${name}`}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
onFocus={() => {
|
||||
onFocus();
|
||||
onTyping(true);
|
||||
}}
|
||||
onBlur={() => onTyping(false)}
|
||||
onKeyDown={(e) => {
|
||||
// The keymap never takes a key from a text field, and this field is the one that wants
|
||||
// to hand this one over: Tab is documented as the next item and it means that here.
|
||||
if (e.key !== "Tab") return;
|
||||
e.preventDefault();
|
||||
onStep(e.shiftKey ? -1 : 1);
|
||||
}}
|
||||
/>
|
||||
<div className="focus-reply-foot">
|
||||
<Button
|
||||
variant="primary"
|
||||
keycap={cap("send")}
|
||||
disabled={!view || draft.trim().length === 0}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button variant="ghost" keycap={cap("focus-next")} onClick={onSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default FocusReply;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { Button, EmptyState, GroupHead, Icon, NO_AUTOFILL, Sheet, icons } from "../ui";
|
||||
import { isTauri } from "../ipc";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import { notify } from "../store/useToast";
|
||||
import { ArticleView } from "./guide/Article";
|
||||
import { FIRST, articleOf, filterSections, orderOf } from "./guide/content";
|
||||
import "./guide.css";
|
||||
|
||||
/**
|
||||
* The guide: how to do things, and the questions the app raises by being unlike the others.
|
||||
*
|
||||
* A panel over the whole window rather than a stage under the header. It is read about the app
|
||||
* rather than instead of it, so nothing behind it can be pressed while it is up and closing it puts
|
||||
* back exactly what was there: the place, the thread, the scroll position, all of it. As a stage it
|
||||
* sat under a header whose own controls did nothing, because a stage wins over a place and pressing
|
||||
* Inbox up there changed a place nobody could see.
|
||||
*
|
||||
* Inside, the shape is Settings': a rail of names on the left and one page on the right at a
|
||||
* measure prose reads at, for the same reason Settings has it. Fifty short articles is too many for
|
||||
* a list that shows one row at a time.
|
||||
*
|
||||
* Search is above both of them and across the whole panel, because it is what somebody opens this
|
||||
* for. A person with a question does not know which of eight sections owns it, and a field tucked
|
||||
* into the head of the rail reads as a way to tidy the rail rather than as the way in. It takes the
|
||||
* focus on open, so the guide can be opened and typed into in one motion.
|
||||
*
|
||||
* The search is over what the articles say and not only over their titles, because somebody looking
|
||||
* for the page about images is as likely to type "tracker" as "images", and a search that only knew
|
||||
* the headings would answer nothing.
|
||||
*/
|
||||
export function Guide() {
|
||||
const open = useOverlays((s) => s.open) === "guide";
|
||||
const close = useOverlays((s) => s.close);
|
||||
|
||||
return (
|
||||
<Sheet open={open} title="Guide" size="full" onClose={close}>
|
||||
<Pages />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** A new question against the repository. The same one App.tsx reports an issue to. */
|
||||
const ISSUES_URL = "https://github.com/priyanshujain/margin-mail/issues/new";
|
||||
|
||||
/** The issue that a failed search is: labelled a question, titled with what was searched for. */
|
||||
function askUrl(query: string): string {
|
||||
const params = new URLSearchParams({ labels: "question", title: query.trim() });
|
||||
return `${ISSUES_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail and the page, as their own component because a closed sheet never mounts its children:
|
||||
* that is what makes the guide open on its first article every time without anything having to
|
||||
* reset it.
|
||||
*/
|
||||
function Pages() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [openId, setOpenId] = useState(FIRST);
|
||||
const panel = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const shown = useMemo(() => filterSections(query), [query]);
|
||||
const order = useMemo(() => orderOf(shown), [shown]);
|
||||
const article = articleOf(openId);
|
||||
|
||||
// What the rail currently holds, for the keys that walk it. Held in a ref so the handler is bound
|
||||
// once: rebinding it on every keystroke in the field would be a listener added fifty times.
|
||||
const held = useRef(order);
|
||||
useEffect(() => {
|
||||
held.current = order;
|
||||
}, [order]);
|
||||
|
||||
const show = (id: string) => {
|
||||
setOpenId(id);
|
||||
// A cross-link can land on an article the search is hiding, and a rail that no longer shows
|
||||
// what is on screen has stopped saying where you are.
|
||||
if (!held.current.includes(id)) setQuery("");
|
||||
};
|
||||
|
||||
// The panel owns its own arrows while it is up, the way the palette does. It cannot use the app's
|
||||
// `j` and `k` commands any more: an open panel shadows the whole view keymap, which is what keeps
|
||||
// those two from walking the list behind it. A letter is only a key when the field does not have
|
||||
// the focus, or typing "just" into it would go looking through the rail instead.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
||||
const down = e.key === "ArrowDown" || (!typing && e.key === "j");
|
||||
const up = e.key === "ArrowUp" || (!typing && e.key === "k");
|
||||
if (!down && !up) return;
|
||||
e.preventDefault();
|
||||
setOpenId((was) => {
|
||||
const list = held.current;
|
||||
if (list.length === 0) return was;
|
||||
const at = list.indexOf(was);
|
||||
if (at === -1) return list[0];
|
||||
return list[Math.min(Math.max(at + (down ? 1 : -1), 0), list.length - 1)];
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// A new article starts at its heading. Without this, opening a short article from the foot of a
|
||||
// long one lands halfway down a page that has already ended.
|
||||
useEffect(() => {
|
||||
panel.current?.scrollTo({ top: 0 });
|
||||
}, [openId]);
|
||||
|
||||
return (
|
||||
<div className="guide">
|
||||
<label className="guide-search">
|
||||
<Icon d={icons.SEARCH} size={16} />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search the guide"
|
||||
aria-label="Search"
|
||||
data-autofocus
|
||||
{...NO_AUTOFILL}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{shown.length === 0 ? <Nothing query={query} /> : null}
|
||||
|
||||
<div className="guide-body">
|
||||
<nav className="guide-rail" aria-label="Guide">
|
||||
{shown.map((section) => (
|
||||
<Fragment key={section.id}>
|
||||
<GroupHead>{section.title}</GroupHead>
|
||||
{section.articles.map((one) => (
|
||||
<button
|
||||
key={one.id}
|
||||
type="button"
|
||||
className="guide-tab"
|
||||
data-active={one.id === openId ? "" : undefined}
|
||||
aria-current={one.id === openId ? "page" : undefined}
|
||||
onClick={() => show(one.id)}
|
||||
>
|
||||
{one.title}
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="guide-panel" ref={panel}>
|
||||
{article ? <ArticleView article={article} onOpen={show} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A search nothing answered, which is the one thing in a guide that is not a failure of the guide:
|
||||
* it is a question nobody has written the page for yet, and the person holding it is the only one
|
||||
* who can say what it was.
|
||||
*
|
||||
* So the offer is to ask rather than to try other words. What it costs is said before it is
|
||||
* pressed, because it leaves the app for somebody else's website and carries what was typed with
|
||||
* it, and neither of those is something to find out afterwards.
|
||||
*/
|
||||
function Nothing({ query }: { query: string }) {
|
||||
const ask = () => {
|
||||
const url = askUrl(query);
|
||||
if (!isTauri) window.open(url, "_blank", "noopener,noreferrer");
|
||||
else openUrl(url).catch((e) => notify(`Could not open the browser: ${e}`));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="guide-nothing">
|
||||
<EmptyState>Nothing here answers that</EmptyState>
|
||||
<p className="guide-ask">
|
||||
Ask on GitHub opens the project's issues in your browser, as a new question titled with what
|
||||
you typed. That page is public.
|
||||
</p>
|
||||
<Button onClick={ask}>Ask on GitHub</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Guide;
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Icon,
|
||||
icons,
|
||||
Key,
|
||||
Popover,
|
||||
Segment,
|
||||
type SegmentOption,
|
||||
} from "../ui";
|
||||
import { keyLabel } from "../keys/bindings";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { busy, trouble, useSync } from "../store/useSync";
|
||||
import { runCommand } from "../keys/commands";
|
||||
import { accountHue, displayName } from "./format";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import type { Place } from "../ipc";
|
||||
import "./header.css";
|
||||
|
||||
/** The three boxes, and the only navigation that is on screen without being asked for. */
|
||||
const BOXES: SegmentOption[] = [
|
||||
{ id: "inbox", label: "Inbox", keycap: "1" },
|
||||
{ id: "feed", label: "Feed", keycap: "2" },
|
||||
{ id: "paper-trail", label: "Paper Trail", keycap: "3" },
|
||||
];
|
||||
|
||||
/**
|
||||
* On macOS Tauri decides the zoom on release rather than on the second press, so it leaves that
|
||||
* press alone and WebKit selects the word under it. Only a press that landed on the drag region
|
||||
* itself is quietened: a double-click in the search box still has a word to pick.
|
||||
*/
|
||||
export function quietDoubleClick(e: MouseEvent<HTMLElement>) {
|
||||
if (
|
||||
e.detail >= 2 &&
|
||||
e.target instanceof HTMLElement &&
|
||||
e.target.hasAttribute("data-tauri-drag-region")
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const place = useMail((s) => s.place);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const setAccount = useMail((s) => s.setAccount);
|
||||
const goTo = useMail((s) => s.goTo);
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const statuses = useSync((s) => s.statuses);
|
||||
const syncing = useSync((s) => s.phase);
|
||||
const show = useOverlays((s) => s.show);
|
||||
const showSettings = useSettings((s) => s.show);
|
||||
const settingsOpen = useSettings((s) => s.open);
|
||||
const closeSettings = useSettings((s) => s.close);
|
||||
|
||||
const [switcher, setSwitcher] = useState(false);
|
||||
const chip = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const current = accounts.find((a) => a.id === accountId) ?? null;
|
||||
const note = trouble(statuses, accountId);
|
||||
// The engine's own sentence when it has one. A pass somebody asked for by hand mostly has none:
|
||||
// it lists, finds nothing new and stops, and for those seconds the only sign it is running at all
|
||||
// was the network light. So the ask itself gets a line, in the same slot, until it comes back.
|
||||
const working =
|
||||
busy(statuses, accountId) ?? (syncing === "syncing" ? "Checking for mail" : null);
|
||||
|
||||
return (
|
||||
// The traffic lights float over the left end of this row, so the row is what drags the window
|
||||
// and zooms it on a double-click, and none of the controls in it do: a button that also drags
|
||||
// swallows its own click. The lanes and the status line carry the attribute as well, because
|
||||
// that is where the empty space actually is, and a bare region only answers to a press that
|
||||
// lands on it directly.
|
||||
<header className="titlebar" data-tauri-drag-region onMouseDown={quietDoubleClick}>
|
||||
<div className="titlebar-lead" data-tauri-drag-region>
|
||||
<button
|
||||
type="button"
|
||||
className="account-chip"
|
||||
ref={chip}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={switcher}
|
||||
onClick={() => setSwitcher((was) => !was)}
|
||||
>
|
||||
{current ? (
|
||||
<Avatar name={current.name} address={current.email} hue={accountHue(current.color)} size="xs" />
|
||||
) : (
|
||||
<Icon d={icons.PILE} size={16} />
|
||||
)}
|
||||
{current ? current.email : "All accounts"}
|
||||
<Icon d={icons.CHEVRON_DOWN} size={12} />
|
||||
</button>
|
||||
|
||||
{/* One line, never two, in the lane the account chip is already in: the boxes are centred
|
||||
in their own grid column and the buttons are right-aligned in theirs, so this can grow
|
||||
and shrink all day without moving anything a hand is aiming at. What is wrong wins over
|
||||
what is happening, and neither is ever a spinner, a bar or something to click. */}
|
||||
{note ? (
|
||||
<span className="sync-note" data-tauri-drag-region>
|
||||
{note}
|
||||
</span>
|
||||
) : working ? (
|
||||
<span className="sync-busy" data-tauri-drag-region>
|
||||
{working}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Going to a box leaves Settings, because Settings is a place and you cannot be in two.
|
||||
The keyboard already knew that: the place commands in App.tsx close it. This did not, so
|
||||
clicking Inbox from Settings changed the place underneath and left Settings on top of it,
|
||||
with no way out but the keyboard. While Settings is up nothing here is selected, because
|
||||
claiming Inbox is selected under a screen that is not the Inbox is the same lie. */}
|
||||
<Segment
|
||||
options={BOXES}
|
||||
value={settingsOpen ? "" : place}
|
||||
label="Boxes"
|
||||
onChange={(id) => {
|
||||
closeSettings();
|
||||
goTo(id as Place);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="titlebar-trail" data-tauri-drag-region>
|
||||
<SearchBar />
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.PLACES}
|
||||
title={`Places (${keyLabel("cmd+k")})`}
|
||||
onClick={() => show("palette")}
|
||||
/>
|
||||
<Button variant="primary" icon={icons.PEN} keycap="c" onClick={() => runCommand("compose")}>
|
||||
Write
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
open={switcher}
|
||||
anchor={chip.current}
|
||||
onClose={() => setSwitcher(false)}
|
||||
label="Accounts"
|
||||
width={280}
|
||||
>
|
||||
<ul className="account-list">
|
||||
{accounts.map((account, index) => (
|
||||
<li key={account.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="account-option"
|
||||
data-active={account.id === accountId ? "" : undefined}
|
||||
onClick={() => {
|
||||
setAccount(account.id);
|
||||
setSwitcher(false);
|
||||
}}
|
||||
>
|
||||
<Avatar name={account.name} address={account.email} hue={accountHue(account.color)} size="sm" />
|
||||
<span className="account-who">
|
||||
<span className="account-name">{displayName({ name: account.name, address: account.email })}</span>
|
||||
<span className="account-address">{account.email}</span>
|
||||
</span>
|
||||
<Key>{keyLabel(`ctrl+${index + 1}`)}</Key>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="account-option"
|
||||
data-active={accountId === null ? "" : undefined}
|
||||
onClick={() => {
|
||||
setAccount(null);
|
||||
setSwitcher(false);
|
||||
}}
|
||||
>
|
||||
<Icon d={icons.PILE} size={20} />
|
||||
<span className="account-who">
|
||||
<span className="account-name">All accounts</span>
|
||||
<span className="account-address">Every mailbox in one list</span>
|
||||
</span>
|
||||
<Key>{keyLabel("ctrl+0")}</Key>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* docs/settings.md names four ways in and this is one of them. Not an account option:
|
||||
it goes somewhere rather than switching what the list is showing. */}
|
||||
<button
|
||||
type="button"
|
||||
className="account-settings"
|
||||
onClick={() => {
|
||||
setSwitcher(false);
|
||||
showSettings();
|
||||
}}
|
||||
>
|
||||
<span>Settings</span>
|
||||
<Key>{keyLabel("cmd+,")}</Key>
|
||||
</button>
|
||||
</Popover>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon, Key, Popover, icons } from "../ui";
|
||||
import { runCommand } from "../keys/commands";
|
||||
import { labelFor, type CommandId } from "../keys/bindings";
|
||||
import { comboOf, useKeyContext } from "../keys/keymap";
|
||||
import { useCompose } from "../store/useCompose";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import { cap } from "./format";
|
||||
import "./help.css";
|
||||
|
||||
/**
|
||||
* The question mark in the bottom right corner, and the small menu behind it.
|
||||
*
|
||||
* It is the only permanent chrome the app has, so it is a ghost button on the paper rather than the
|
||||
* coloured bubble every support widget in the world is: somebody who never needs it should be able
|
||||
* to work all day without noticing it is there.
|
||||
*
|
||||
* Three rows and each of them is a command, so the menu, the palette and the keyboard are one code
|
||||
* path. The labels come off the binding table for the same reason the shortcut sheet does, which is
|
||||
* that a menu with its own copy of them is a menu that will one day disagree with the palette.
|
||||
*/
|
||||
|
||||
interface HelpRow {
|
||||
command: CommandId;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const ROWS: readonly HelpRow[] = [
|
||||
{ command: "tour", icon: icons.COMPASS },
|
||||
{ command: "guide", icon: icons.BOOK },
|
||||
{ command: "shortcuts", icon: icons.HELP },
|
||||
];
|
||||
|
||||
export function HelpButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const button = useRef<HTMLButtonElement | null>(null);
|
||||
// Whether there is a card rather than which card, so a draft being typed into is not a render of
|
||||
// this button per keystroke.
|
||||
const writing = useCompose((s) => s.card !== null);
|
||||
const overlay = useOverlays((s) => s.open);
|
||||
|
||||
// Two ways to be in the way. The compose card takes this exact corner and sits above it, and an
|
||||
// overlay has the window and would leave a button floating over its scrim, which covers the guide
|
||||
// and the tour as well since both are panels. The phone is the third: the tab bar owns the corner
|
||||
// there, and that one is a rule in the stylesheet rather than a branch here.
|
||||
const hidden = writing || overlay !== null;
|
||||
|
||||
// A row opens something that hides the button, and the menu goes with it. Without this the menu
|
||||
// would be back, still open, the moment the thing it opened was closed.
|
||||
useEffect(() => {
|
||||
if (hidden) setOpen(false);
|
||||
}, [hidden]);
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="help-launcher">
|
||||
<Button
|
||||
ref={button}
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.HELP}
|
||||
active={open}
|
||||
title="Help"
|
||||
label="Help"
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
/>
|
||||
</div>
|
||||
{/* Above the anchor, because the anchor is 20px off the bottom of the window and a menu that
|
||||
opened downwards from it would be a menu nobody can read. */}
|
||||
<Popover
|
||||
open={open}
|
||||
anchor={button.current}
|
||||
onClose={close}
|
||||
placement="top-end"
|
||||
width={240}
|
||||
label="Help"
|
||||
>
|
||||
<HelpList onClose={close} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows, as their own component because the popover draws its children a frame after it is asked
|
||||
* to open, once it knows where the anchor is. Focusing the first row belongs to the moment the rows
|
||||
* exist, which is this component's mount.
|
||||
*/
|
||||
function HelpList({ onClose }: { onClose: () => void }) {
|
||||
const list = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
// In front of the window, so an arrow key here does not also walk the list behind it.
|
||||
useKeyContext("overlay");
|
||||
|
||||
useEffect(() => {
|
||||
list.current?.querySelector<HTMLButtonElement>(".help-option")?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const step = (delta: number) => {
|
||||
const rows = [...(list.current?.querySelectorAll<HTMLButtonElement>(".help-option") ?? [])];
|
||||
if (rows.length === 0) return;
|
||||
const at = rows.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next =
|
||||
at === -1 ? (delta > 0 ? 0 : rows.length - 1) : (at + delta + rows.length) % rows.length;
|
||||
rows[next].focus();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
const combo = comboOf(e);
|
||||
if (combo !== "ArrowDown" && combo !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
step(combo === "ArrowDown" ? 1 : -1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, []);
|
||||
|
||||
const choose = (command: CommandId) => {
|
||||
onClose();
|
||||
runCommand(command);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="help-menu">
|
||||
<ul className="help-list" role="menu" ref={list}>
|
||||
{ROWS.map((row) => {
|
||||
const key = cap(row.command);
|
||||
return (
|
||||
<li key={row.command} role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="help-option"
|
||||
onClick={() => choose(row.command)}
|
||||
>
|
||||
<Icon d={row.icon} size={15} />
|
||||
<span className="help-label">{labelFor(row.command)}</span>
|
||||
{key ? <Key size="sm">{key}</Key> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HelpButton;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { Button, Icon, icons } from "../ui";
|
||||
import { registerCommands } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import { inviteRespond } from "../api/write";
|
||||
import { CALENDAR_SCOPE, isTauri, type Invite, type InviteResponse } from "../ipc";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { notify } from "../store/useToast";
|
||||
import { displayName } from "./format";
|
||||
import "./invite.css";
|
||||
|
||||
/**
|
||||
* A `text/calendar` invitation, as a card in the pane.
|
||||
*
|
||||
* The interesting part is the permission. Answering needs the Calendar scope, the app does not ask
|
||||
* for it when an account is added, and Google has no incremental grant for an installed app: the
|
||||
* only way to get it is to run the whole consent again. So the card renders whatever the account
|
||||
* was given, read only when the scope is not there, with one sentence saying why and a Grant that
|
||||
* runs consent. docs/features.md promises exactly that, and a card that hid itself instead would
|
||||
* lose the date and the time as well as the buttons.
|
||||
*
|
||||
* `y`, `m` and `n` mean accept, maybe and decline while the card holds the focus, which is the one
|
||||
* exception docs/keyboard.md allows to keys never being reused. The card is the only thing that can
|
||||
* receive them, and it prints all three.
|
||||
*/
|
||||
|
||||
const RESPONSES: { response: InviteResponse; label: string; keycap: string }[] = [
|
||||
{ response: "accepted", label: "Accept", keycap: "y" },
|
||||
{ response: "tentative", label: "Maybe", keycap: "m" },
|
||||
{ response: "declined", label: "Decline", keycap: "n" },
|
||||
];
|
||||
|
||||
const ANSWERED: Record<InviteResponse, string> = {
|
||||
accepted: "Going",
|
||||
tentative: "Maybe",
|
||||
declined: "Not going",
|
||||
"needs-action": "",
|
||||
};
|
||||
|
||||
const month = new Intl.DateTimeFormat(undefined, { month: "short" });
|
||||
const dayNumber = new Intl.DateTimeFormat(undefined, { day: "numeric" });
|
||||
const longDay = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
const clock = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** "Wednesday 10 September · 17:00 to 17:45 · Sunny Day Music, Bandra". */
|
||||
function when(invite: Invite): string {
|
||||
const parts = [longDay.format(invite.startMs)];
|
||||
if (!invite.allDay) parts.push(`${clock.format(invite.startMs)} to ${clock.format(invite.endMs)}`);
|
||||
else parts.push("All day");
|
||||
if (invite.location) parts.push(invite.location);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Whether an error from `invite_respond` is the app being short a permission rather than a fault. */
|
||||
const aboutTheScope = (message: string): boolean =>
|
||||
message.includes(CALENDAR_SCOPE) || /scope|permission|calendar/i.test(message);
|
||||
|
||||
interface InviteCardProps {
|
||||
invite: Invite;
|
||||
/** The provider's message id, which is what `invite_respond` takes. */
|
||||
messageId: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function InviteCard({ invite, messageId, accountId }: InviteCardProps) {
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const grant = useAccounts((s) => s.grant);
|
||||
const [answer, setAnswer] = useState<InviteResponse>(invite.myResponse);
|
||||
const [phase, setPhase] = useState<"idle" | "sending" | "no-scope">("idle");
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEffect(() => setAnswer(invite.myResponse), [invite.myResponse, invite.uid]);
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
// What Google actually granted, because a person can untick a scope on the consent screen and a
|
||||
// feature that assumed otherwise would offer a button that always fails.
|
||||
const granted = account?.grantedScopes.includes(CALENDAR_SCOPE) ?? false;
|
||||
const readOnly = !granted || phase === "no-scope";
|
||||
|
||||
const respond = (response: InviteResponse) => {
|
||||
if (readOnly || phase === "sending") return;
|
||||
const before = answer;
|
||||
setAnswer(response);
|
||||
setPhase("sending");
|
||||
void inviteRespond(messageId, response)
|
||||
.then(() => {
|
||||
setPhase("idle");
|
||||
notify(`${ANSWERED[response]} · ${invite.summary}`);
|
||||
void useMail.getState().open(useMail.getState().openKey ?? undefined);
|
||||
})
|
||||
.catch((e) => {
|
||||
setAnswer(before);
|
||||
const message = String(e);
|
||||
// The one failure that is not a failure: the app is short a scope and can ask for it.
|
||||
if (aboutTheScope(message)) setPhase("no-scope");
|
||||
else {
|
||||
setPhase("idle");
|
||||
notify(`That did not go through: ${message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// `y`, `m` and `n` are the card's only while the card has the focus. The handler is reached
|
||||
// through a ref so that the registration is one push and one pop rather than one of each on every
|
||||
// keystroke that changes the answer.
|
||||
const latest = useRef(respond);
|
||||
latest.current = respond;
|
||||
useKeyContext("invite", focused && !readOnly);
|
||||
useEffect(() => {
|
||||
if (!focused || readOnly) return;
|
||||
return registerCommands({
|
||||
"invite-accept": () => latest.current("accepted"),
|
||||
"invite-maybe": () => latest.current("tentative"),
|
||||
"invite-decline": () => latest.current("declined"),
|
||||
});
|
||||
}, [focused, readOnly]);
|
||||
|
||||
const open = () => {
|
||||
if (!invite.calendarLink) return;
|
||||
if (!isTauri) window.open(invite.calendarLink, "_blank", "noopener,noreferrer");
|
||||
else openUrl(invite.calendarLink).catch((e) => notify(`Could not open the calendar: ${e}`));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="invite"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`Invitation: ${invite.summary}`}
|
||||
data-focus={focused ? "" : undefined}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
|
||||
}}
|
||||
>
|
||||
<div className="invite-date">
|
||||
<span className="mon">{month.format(invite.startMs)}</span>
|
||||
<span className="day">{dayNumber.format(invite.startMs)}</span>
|
||||
</div>
|
||||
<div className="invite-main">
|
||||
<div className="invite-title">{invite.summary}</div>
|
||||
<div className="invite-when">{when(invite)}</div>
|
||||
{invite.organizer ? (
|
||||
<div className="invite-organizer">{`Organised by ${displayName(invite.organizer)}`}</div>
|
||||
) : null}
|
||||
|
||||
{readOnly ? (
|
||||
<p className="invite-scope">
|
||||
Answering an invitation needs Google Calendar, which this account has not given Margin
|
||||
yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="invite-actions">
|
||||
{readOnly ? (
|
||||
<Button variant="primary" onClick={() => void grant(accountId, [CALENDAR_SCOPE])}>
|
||||
Grant
|
||||
</Button>
|
||||
) : (
|
||||
RESPONSES.map(({ response, label, keycap }) => (
|
||||
<Button
|
||||
key={response}
|
||||
variant={answer === response ? "primary" : "default"}
|
||||
keycap={keycap}
|
||||
active={answer === response}
|
||||
disabled={phase === "sending"}
|
||||
onClick={() => respond(response)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
{answer !== "needs-action" && !readOnly ? (
|
||||
<span className="invite-answered">
|
||||
<Icon d={icons.CHECK} size={12} />
|
||||
{ANSWERED[answer]}
|
||||
</span>
|
||||
) : null}
|
||||
{invite.calendarLink ? (
|
||||
<button type="button" className="invite-open" onClick={open}>
|
||||
Open in Margin Calendar
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteCard;
|
||||
@@ -0,0 +1,613 @@
|
||||
// Every primitive in every state, on one page, in both palettes.
|
||||
//
|
||||
// This is how a restyle is reviewed and how the UI suite proves the design system still looks like
|
||||
// itself: two screenshots, light and dark, compared against the mockups by eye. It imports nothing
|
||||
// but React and src/ui, which is the point. If something on a screen cannot be built out of what
|
||||
// is on this page, the thing to change is a primitive and not the screen.
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarStack,
|
||||
Banner,
|
||||
Button,
|
||||
Confirm,
|
||||
EmptyState,
|
||||
Field,
|
||||
GroupHead,
|
||||
Icon,
|
||||
icons,
|
||||
Key,
|
||||
Palette,
|
||||
Pill,
|
||||
Popover,
|
||||
Row,
|
||||
Segment,
|
||||
Sheet,
|
||||
Toast,
|
||||
Toggle,
|
||||
} from "../ui";
|
||||
import "./kit.css";
|
||||
|
||||
function Section({ title, note, children }: { title: string; note?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="kit-section">
|
||||
<h2 className="kit-title">{title}</h2>
|
||||
{note ? <p className="kit-note">{note}</p> : null}
|
||||
<div className="kit-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Bench({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="kit-bench">
|
||||
<span className="kit-label">{label}</span>
|
||||
<div className="kit-items">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A bounded box that catches the fixed positioning of an overlay, so a sheet, the palette and a
|
||||
* toast can be seen in place on a page rather than over it. */
|
||||
function Stage({
|
||||
label,
|
||||
size,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
size?: "short" | "tall";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="kit-bench">
|
||||
<span className="kit-label">{label}</span>
|
||||
<div
|
||||
className="kit-stage"
|
||||
data-short={size === "short" ? "" : undefined}
|
||||
data-tall={size === "tall" ? "" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PALETTE_GROUPS = [
|
||||
{
|
||||
id: "places",
|
||||
label: "Places",
|
||||
items: [
|
||||
{ id: "inbox", label: "Inbox", keys: ["1"] },
|
||||
{ id: "feed", label: "Feed", keys: ["2"] },
|
||||
{ id: "trail", label: "Paper Trail", keys: ["3"] },
|
||||
{ id: "later", label: "Reply later", keys: ["4"] },
|
||||
{ id: "aside", label: "Set aside", keys: ["5"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
items: [
|
||||
{ id: "archive", label: "Archive", keys: ["e"] },
|
||||
{ id: "snooze", label: "Snooze", hint: "later today, tomorrow, the weekend", keys: ["b"] },
|
||||
{ id: "note", label: "Note", keys: ["y"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
items: [
|
||||
{ id: "undo", label: "Undo delay", hint: "10 seconds" },
|
||||
{ id: "fonts", label: "Fonts", hint: "Hanken Grotesk and Literata" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Kit() {
|
||||
const [theme, setTheme] = useState(
|
||||
() => document.documentElement.getAttribute("data-theme") ?? "light",
|
||||
);
|
||||
const [phone, setPhone] = useState(() => document.documentElement.hasAttribute("data-phone"));
|
||||
const [touch, setTouch] = useState(() => document.documentElement.hasAttribute("data-touch"));
|
||||
|
||||
const [text, setText] = useState("Priyanshu Jain");
|
||||
const [note, setNote] = useState("Always cc the studio address on anything about the lease.");
|
||||
const [query, setQuery] = useState("");
|
||||
const [notify, setNotify] = useState(true);
|
||||
const [images, setImages] = useState(false);
|
||||
const [box, setBox] = useState("inbox");
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const setThemeAttr = (next: string) => {
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
setTheme(next);
|
||||
};
|
||||
const setPhoneAttr = (next: boolean) => {
|
||||
document.documentElement.toggleAttribute("data-phone", next);
|
||||
setPhone(next);
|
||||
};
|
||||
const setTouchAttr = (next: boolean) => {
|
||||
document.documentElement.toggleAttribute("data-touch", next);
|
||||
setTouch(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kit">
|
||||
<header className="kit-head">
|
||||
<h1 className="kit-heading">Margin Mail, the kit</h1>
|
||||
<div className="kit-controls">
|
||||
<Segment
|
||||
label="Theme"
|
||||
value={theme}
|
||||
onChange={setThemeAttr}
|
||||
options={[
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
]}
|
||||
/>
|
||||
<Toggle checked={phone} onChange={setPhoneAttr} label="data-phone" />
|
||||
<Toggle checked={touch} onChange={setTouchAttr} label="data-touch" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Section
|
||||
title="Row"
|
||||
note="Two lines at 58px. The list is the app, so this is the one to get right."
|
||||
>
|
||||
<div className="kit-list">
|
||||
<Row
|
||||
unread
|
||||
sender="Maya Raghunathan"
|
||||
address="[email protected]"
|
||||
time="11:42"
|
||||
count={3}
|
||||
subject="Dinner on Thursday?"
|
||||
snippet="Priya said the place on Church Street takes bookings"
|
||||
/>
|
||||
<Row
|
||||
unread
|
||||
selected
|
||||
sender="Arun Kulkarni"
|
||||
address="[email protected]"
|
||||
time="10:15"
|
||||
subject="Re: Lease renewal for the studio"
|
||||
snippet="Attached the revised draft. The only change is clause 7"
|
||||
/>
|
||||
<Row
|
||||
unread
|
||||
brand
|
||||
sender="Airbnb"
|
||||
address="[email protected]"
|
||||
time="09:03"
|
||||
subject="Your reservation in Lisbon is confirmed"
|
||||
snippet="Check-in Friday 12 September after 15:00"
|
||||
/>
|
||||
<Row
|
||||
unread
|
||||
mark={icons.PAPERCLIP}
|
||||
sender="Sam Okafor"
|
||||
address="[email protected]"
|
||||
time="Yesterday"
|
||||
subject="Piano lessons, the form you sent"
|
||||
snippet="Got it, thank you. Wednesdays at five work for us"
|
||||
/>
|
||||
<div>
|
||||
<Row
|
||||
sender="Lena Brandt"
|
||||
address="[email protected]"
|
||||
time="Yesterday"
|
||||
count={7}
|
||||
subject="Kitchen bench quote"
|
||||
snippet="Sounds good, Julie. Any afternoon next week works"
|
||||
note="Ask about the oak finish before confirming"
|
||||
/>
|
||||
<Row
|
||||
sender="Dev Patel"
|
||||
address="[email protected]"
|
||||
time="Mon"
|
||||
accountHue={4}
|
||||
subject="Slides from the talk"
|
||||
snippet="Here they are, plus the reading list I mentioned"
|
||||
/>
|
||||
<Row
|
||||
brand
|
||||
sender="DocuSign"
|
||||
address="[email protected]"
|
||||
time="Mon"
|
||||
mark={icons.STAR}
|
||||
subject="Completed: Studio lease 2026"
|
||||
snippet="All parties have completed the envelope"
|
||||
/>
|
||||
<Row
|
||||
selecting
|
||||
checked
|
||||
sender="Hannah Weiss"
|
||||
address="[email protected]"
|
||||
time="Sun"
|
||||
count={2}
|
||||
subject="Photos from the Hawaii trip"
|
||||
snippet="Finally went through them all"
|
||||
/>
|
||||
<Row
|
||||
selecting
|
||||
sender="Russell Young"
|
||||
address="[email protected]"
|
||||
time="30 Aug"
|
||||
subject="Pumpkin bread recipe"
|
||||
snippet="From my mother's card, transcribed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Popover" note="Anchored to what it explains: the contact card, the snooze picker.">
|
||||
<div className="kit-bench kit-hang">
|
||||
<span className="kit-label">anchored, open</span>
|
||||
<div className="kit-items">
|
||||
<span className="kit-anchor" ref={setAnchor}>
|
||||
<Button icon={icons.INBOX} keycap="i">
|
||||
Arun Kulkarni
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Popover open anchor={anchor} onClose={() => {}} label="Arun Kulkarni">
|
||||
<div className="popover-head">
|
||||
<Avatar name="Arun Kulkarni" address="[email protected]" size="lg" />
|
||||
<span>
|
||||
<div className="popover-name">Arun Kulkarni</div>
|
||||
<div className="popover-sub">arun@meridianproperties.in</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="popover-rows">
|
||||
<div className="popover-row">
|
||||
<span className="lab">Delivers to</span>
|
||||
<span className="val">
|
||||
<Pill tone="wash">Inbox</Pill>
|
||||
</span>
|
||||
</div>
|
||||
<div className="popover-row">
|
||||
<span className="lab">Notify</span>
|
||||
<span className="val">On for this thread</span>
|
||||
</div>
|
||||
<div className="popover-row">
|
||||
<span className="lab">Screened</span>
|
||||
<span className="val">12 March, to Inbox</span>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</Section>
|
||||
|
||||
<Section title="Button" note="Every one carries the key its verb answers to.">
|
||||
<Bench label="variants">
|
||||
<Button>Default</Button>
|
||||
<Button variant="primary">Send</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="danger">Delete</Button>
|
||||
</Bench>
|
||||
<Bench label="with a key">
|
||||
<Button icon={icons.REPLY} keycap="r">
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="primary" icon={icons.PEN} keycap="c">
|
||||
Write
|
||||
</Button>
|
||||
<Button variant="ghost" icon={icons.CLOCK} keycap="l">
|
||||
Reply later
|
||||
</Button>
|
||||
<Button variant="ghost" icon={icons.SET_ASIDE} keycap="s">
|
||||
Set aside
|
||||
</Button>
|
||||
<Button variant="ghost" icon={icons.SNOOZE} keycap="b">
|
||||
Snooze
|
||||
</Button>
|
||||
<Button variant="ghost" icon={icons.ARCHIVE} keycap="e">
|
||||
Archive
|
||||
</Button>
|
||||
<Button variant="danger" icon={icons.TRASH} keycap="#">
|
||||
Trash
|
||||
</Button>
|
||||
</Bench>
|
||||
<Bench label="sizes">
|
||||
<Button size="sm" keycap="a">
|
||||
Small
|
||||
</Button>
|
||||
<Button size="md" keycap="a">
|
||||
Medium
|
||||
</Button>
|
||||
<Button size="lg" keycap="a">
|
||||
Large
|
||||
</Button>
|
||||
</Bench>
|
||||
<Bench label="icon only">
|
||||
<Button iconOnly icon={icons.SEARCH} title="Search (/)" />
|
||||
<Button iconOnly icon={icons.PLACES} title="Places (⌘K)" />
|
||||
<Button iconOnly icon={icons.MORE} title="More actions (.)" />
|
||||
<Button iconOnly variant="ghost" icon={icons.CLOSE} title="Close (⎋)" />
|
||||
<Button iconOnly variant="ghost" active icon={icons.BELL} title="Notify me (⇧N)" />
|
||||
</Bench>
|
||||
<Bench label="disabled">
|
||||
<Button disabled>Default</Button>
|
||||
<Button variant="primary" disabled>
|
||||
Send
|
||||
</Button>
|
||||
<Button variant="ghost" disabled>
|
||||
Ghost
|
||||
</Button>
|
||||
<Button variant="danger" disabled>
|
||||
Delete
|
||||
</Button>
|
||||
</Bench>
|
||||
</Section>
|
||||
|
||||
<Section title="Key" note="The cap on its own, for the palette and the shortcut sheet.">
|
||||
<Bench label="standing alone">
|
||||
<Key>j</Key>
|
||||
<Key>k</Key>
|
||||
<Key>⏎</Key>
|
||||
<Key>⎋</Key>
|
||||
<Key>⌘K</Key>
|
||||
<Key>⇧S</Key>
|
||||
</Bench>
|
||||
<Bench label="inside a control">
|
||||
<Key size="sm">1</Key>
|
||||
<Key size="sm">e</Key>
|
||||
<Key size="sm">z</Key>
|
||||
</Bench>
|
||||
</Section>
|
||||
|
||||
<Section title="Avatar" note="Initials on one of the eight hues, chosen from the address.">
|
||||
<Bench label="sizes">
|
||||
<Avatar name="Priyanshu Jain" address="[email protected]" size="xs" />
|
||||
<Avatar name="Priyanshu Jain" address="[email protected]" size="sm" />
|
||||
<Avatar name="Priyanshu Jain" address="[email protected]" />
|
||||
<Avatar name="Priyanshu Jain" address="[email protected]" size="lg" />
|
||||
</Bench>
|
||||
<Bench label="the eight hues">
|
||||
{["a", "b", "c", "d", "e", "f", "g", "h"].map((seed, i) => (
|
||||
<Avatar key={seed} name={`Hue ${i + 1}`} address={`${seed}@example.com`} />
|
||||
))}
|
||||
</Bench>
|
||||
<Bench label="brand marks">
|
||||
<Avatar brand name="Airbnb" address="[email protected]" />
|
||||
<Avatar brand name="DocuSign" address="[email protected]" />
|
||||
<Avatar brand name="Stripe" address="[email protected]" />
|
||||
</Bench>
|
||||
<Bench label="stacked participants">
|
||||
<AvatarStack
|
||||
people={[
|
||||
{ name: "Arun Kulkarni", address: "[email protected]" },
|
||||
{ name: "Priyanshu Jain", address: "[email protected]" },
|
||||
{ name: "Maya Raghunathan", address: "[email protected]" },
|
||||
]}
|
||||
/>
|
||||
<span className="kit-quiet">Arun Kulkarni and you · 2 messages</span>
|
||||
</Bench>
|
||||
</Section>
|
||||
|
||||
<Section title="Pill">
|
||||
<Bench label="tones">
|
||||
<Pill icon={icons.SHIELD} keycap="6" onClick={() => {}}>
|
||||
Screen 3 new senders
|
||||
</Pill>
|
||||
<Pill tone="wash">Written by a person · suggested Inbox</Pill>
|
||||
<Pill tone="quiet" onClick={() => {}}>
|
||||
··· Show quoted text
|
||||
</Pill>
|
||||
<Pill>2 messages</Pill>
|
||||
</Bench>
|
||||
</Section>
|
||||
|
||||
<Section title="Segment" note="The three boxes, each printing its number.">
|
||||
<Bench label="places">
|
||||
<Segment
|
||||
label="Places"
|
||||
value={box}
|
||||
onChange={setBox}
|
||||
options={[
|
||||
{ id: "inbox", label: "Inbox", keycap: "1" },
|
||||
{ id: "feed", label: "Feed", keycap: "2" },
|
||||
{ id: "trail", label: "Paper Trail", keycap: "3" },
|
||||
]}
|
||||
/>
|
||||
</Bench>
|
||||
<Bench label="disabled, while the last choice is written">
|
||||
<Segment
|
||||
label="Backup store"
|
||||
value="drive"
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
options={[
|
||||
{ id: "none", label: "Off" },
|
||||
{ id: "drive", label: "Google Drive" },
|
||||
{ id: "r2", label: "Cloudflare R2" },
|
||||
]}
|
||||
/>
|
||||
</Bench>
|
||||
</Section>
|
||||
|
||||
<Section title="Toggle">
|
||||
<div className="kit-settings">
|
||||
<Toggle
|
||||
checked={notify}
|
||||
onChange={setNotify}
|
||||
label="Notify me about this thread"
|
||||
note="Off everywhere else until you ask, which is the whole point."
|
||||
/>
|
||||
<Toggle
|
||||
checked={images}
|
||||
onChange={setImages}
|
||||
label="Load remote images"
|
||||
note="Trackers are stripped before anything loads, and the banner names them."
|
||||
/>
|
||||
<Toggle checked={false} onChange={() => {}} label="Send read receipts" disabled />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Field">
|
||||
<div className="kit-form">
|
||||
<Field label="Display name" value={text} onChange={setText} />
|
||||
<Field
|
||||
label="Note about this sender"
|
||||
value={note}
|
||||
onChange={setNote}
|
||||
multiline
|
||||
rows={3}
|
||||
hint="Private to you. It never touches Gmail."
|
||||
/>
|
||||
<Field
|
||||
label="Forward to"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="[email protected]"
|
||||
hint="That address is not on this account."
|
||||
tone="error"
|
||||
/>
|
||||
<Field label="Account" value="[email protected]" onChange={() => {}} disabled />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="GroupHead">
|
||||
<div className="kit-list">
|
||||
<GroupHead>Back</GroupHead>
|
||||
<GroupHead action={{ label: "Show all", onClick: () => {} }}>This week</GroupHead>
|
||||
<GroupHead rule>You left off here</GroupHead>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Banner">
|
||||
<div className="kit-column">
|
||||
<Banner icon={icons.SHIELD} action={{ label: "Show images", onClick: () => {} }}>
|
||||
Blocked <b>1 tracker</b> from HubSpot. Remote images are off for this sender.
|
||||
</Banner>
|
||||
<Banner
|
||||
icon={icons.SHIELD}
|
||||
action={{ label: "Show images", busy: true, busyLabel: "Loading images…", onClick: () => {} }}
|
||||
>
|
||||
Blocked <b>1 tracker</b> from HubSpot. Remote images are off for this sender.
|
||||
</Banner>
|
||||
<Banner tone="muted" icon={icons.MERGE} action={{ label: "Unmerge", onClick: () => {} }}>
|
||||
Merged from two threads.
|
||||
</Banner>
|
||||
<Banner tone="muted" icon={icons.ENVELOPE}>
|
||||
You are ignoring this thread. It will not come back to the top.
|
||||
</Banner>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="EmptyState" note="One quiet line in the text face and nothing else.">
|
||||
<div className="kit-column">
|
||||
<EmptyState>Nothing here</EmptyState>
|
||||
<EmptyState>No one is waiting</EmptyState>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Icon" note="A path, not a set. These are all of them.">
|
||||
<div className="kit-icons">
|
||||
{Object.entries(icons).map(([name, d]) => (
|
||||
<span className="kit-icon" key={name}>
|
||||
<Icon d={d} size={20} />
|
||||
<span className="kit-quiet">{name.toLowerCase().replace(/_/g, " ")}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Toast" note="The only acknowledgement a triage key gives.">
|
||||
<Stage label="sent" size="short">
|
||||
<Toast action={{ label: "Undo", keycap: "z", onClick: () => {} }}>
|
||||
Sent to Arun Kulkarni
|
||||
</Toast>
|
||||
</Stage>
|
||||
<Stage label="archived" size="short">
|
||||
<Toast action={{ label: "Undo", keycap: "z", onClick: () => {} }}>
|
||||
Archived 4 threads
|
||||
</Toast>
|
||||
</Stage>
|
||||
</Section>
|
||||
|
||||
<Section title="Sheet" note="Every panel, and on a phone every bottom sheet.">
|
||||
<Stage label="with a back trail and a foot">
|
||||
<Sheet
|
||||
open
|
||||
title="Note about Arun"
|
||||
onClose={() => {}}
|
||||
onBack={() => {}}
|
||||
backLabel="the contact card"
|
||||
foot={
|
||||
<>
|
||||
<Button>Cancel</Button>
|
||||
<Button variant="primary" keycap="⌘⏎">
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Note" value={note} onChange={setNote} multiline rows={3} />
|
||||
<Toggle checked={notify} onChange={setNotify} label="Notify me about this sender" />
|
||||
</Sheet>
|
||||
</Stage>
|
||||
<Stage label="a confirmation in front of it">
|
||||
<Sheet open title="Screening" onClose={() => {}}>
|
||||
<Confirm
|
||||
title="Screen out Meridian Properties?"
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
Nothing from this address reaches a box again. Nothing is deleted and you can
|
||||
reverse it from their contact card.
|
||||
</p>
|
||||
<label className="confirm-option">
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span>
|
||||
Also move what they have already sent to the bin
|
||||
<span className="confirm-option-note">
|
||||
Left unticked, it stays where it is.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Screen out"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Sheet>
|
||||
</Stage>
|
||||
<Stage label="a confirmation that is running">
|
||||
<Sheet open busy title="Clear the mirror" size="mini" onClose={() => {}}>
|
||||
<Confirm
|
||||
title="Clear the local copy of [email protected]?"
|
||||
body={
|
||||
<p>
|
||||
This deletes the mail on this device and syncs the last month again from Gmail.
|
||||
Nothing is removed from Gmail.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Clear it"
|
||||
busy
|
||||
busyLabel="Clearing"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Sheet>
|
||||
</Stage>
|
||||
</Section>
|
||||
|
||||
<Section title="Palette" note="The shell. The data comes from the keymap and the places.">
|
||||
<Stage label="open" size="tall">
|
||||
<Palette
|
||||
open
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
groups={PALETTE_GROUPS}
|
||||
activeId="later"
|
||||
onChoose={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</Stage>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { Button, EmptyState, GroupHead, icons, Pill, Row } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { GROUPS, type Place, type ThreadSummary } from "../ipc";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useScreener } from "../store/useScreener";
|
||||
import { useSearch } from "../store/useSearch";
|
||||
import { useSelection } from "../store/useSelection";
|
||||
import { useSnooze } from "../store/useSnooze";
|
||||
import { filling, type Fill, useSync } from "../store/useSync";
|
||||
import { cap, displayName, hueOf, isBrand, rowTime } from "./format";
|
||||
import { LabelPicker, type PickerMode } from "./ActionBar";
|
||||
import { Piles } from "./Piles";
|
||||
import { mergeThreads, NoteSheet } from "./ReadingPane";
|
||||
import { QueryTerms } from "./SearchBar";
|
||||
import { returnTime, SnoozePicker, snoozeAnchor } from "./SnoozePicker";
|
||||
import * as triage from "./triage";
|
||||
import "./list.css";
|
||||
|
||||
/** The place's name, in the text face, at the head of its column. */
|
||||
const TITLES: Record<Place, string> = {
|
||||
inbox: "Inbox",
|
||||
feed: "Feed",
|
||||
"paper-trail": "Paper Trail",
|
||||
"reply-later": "Reply later",
|
||||
"set-aside": "Set aside",
|
||||
screener: "Screener",
|
||||
snoozed: "Snoozed",
|
||||
everything: "Everything",
|
||||
sent: "Sent",
|
||||
drafts: "Drafts",
|
||||
starred: "Starred",
|
||||
"screened-out": "Screened out",
|
||||
spam: "Spam",
|
||||
trash: "Trash",
|
||||
label: "Label",
|
||||
search: "Search",
|
||||
};
|
||||
|
||||
/** One quiet line and nothing else. No illustration, no button suggesting you go and make mail. */
|
||||
const EMPTY: Partial<Record<Place, string>> = {
|
||||
screener: "No one is waiting",
|
||||
snoozed: "Nothing due",
|
||||
search: "Nothing on this device",
|
||||
"screened-out": "Nobody has been screened out",
|
||||
spam: "Nothing in spam",
|
||||
trash: "Nothing in the trash",
|
||||
};
|
||||
|
||||
/**
|
||||
* What an empty place shows while its mailbox is still on its way: the engine's own sentence, a
|
||||
* thin bar that fills once there is a total to fill it against, and the count under it. The same
|
||||
* bar the welcome screen draws, on the paper the list is on rather than on the stage, because
|
||||
* from Settings there is an Inbox to sit in while the mail arrives and nothing should take the
|
||||
* window over twice.
|
||||
*/
|
||||
function Filling({ fill }: { fill: Fill }) {
|
||||
const counted = fill.total > 0;
|
||||
const done = counted ? fill.hydrated / fill.total : 0;
|
||||
return (
|
||||
<div className="list-filling" role="status" aria-live="polite">
|
||||
<p className="list-filling-line">{fill.message}</p>
|
||||
<div
|
||||
className="list-filling-bar"
|
||||
data-counting={counted ? undefined : ""}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={counted ? Math.round(done * 100) : undefined}
|
||||
>
|
||||
<span className="list-filling-fill" style={counted ? { transform: `scaleX(${done})` } : undefined} />
|
||||
</div>
|
||||
<p className="list-filling-count">
|
||||
{counted
|
||||
? `${fill.hydrated.toLocaleString()} of ${fill.total.toLocaleString()} messages`
|
||||
: "This becomes your mail as soon as it lands"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Entry =
|
||||
| { kind: "head"; id: string; label: string }
|
||||
| { kind: "row"; id: string; thread: ThreadSummary; rule: boolean };
|
||||
|
||||
/**
|
||||
* Rows arrive already grouped and already ordered, so this walks them once and puts a head in
|
||||
* wherever a group with a head changed. It never sorts and it never decides what a group is
|
||||
* called: the two sides cannot disagree about where a row belongs if only one of them has an
|
||||
* opinion. A group `GROUPS` has no label for (`new` and `seen` in the Inbox) draws nothing: the
|
||||
* Inbox is one list, and a row says it is new by its weight. The first row after a headed group
|
||||
* ends carries a rule, so Back has a bottom as well as a top and the list under it does not read
|
||||
* as more of it.
|
||||
*/
|
||||
function entriesOf(threads: ThreadSummary[]): Entry[] {
|
||||
const out: Entry[] = [];
|
||||
let group = "";
|
||||
let headed = false;
|
||||
for (const thread of threads) {
|
||||
let rule = false;
|
||||
if (thread.group !== group) {
|
||||
group = thread.group;
|
||||
const label = GROUPS[group];
|
||||
if (label) out.push({ kind: "head", id: `head:${group}`, label });
|
||||
rule = headed && !label;
|
||||
headed = Boolean(label);
|
||||
}
|
||||
out.push({ kind: "row", id: thread.key, thread, rule });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One small glyph before the time. A star is a state you set; a paperclip is one the mail has;
|
||||
* trash and spam are where something else put it, which is what a search result most needs to say
|
||||
* and why they come first. Not drawn in Trash or Spam themselves, where a glyph on every row is
|
||||
* noise saying what the title already says.
|
||||
*/
|
||||
const markOf = (thread: ThreadSummary, place: Place): { mark: string; title: string } | undefined =>
|
||||
thread.trashed && place !== "trash"
|
||||
? { mark: icons.TRASH, title: "Trash" }
|
||||
: thread.spam && place !== "spam"
|
||||
? { mark: icons.SPAM, title: "Spam" }
|
||||
: thread.starred
|
||||
? { mark: icons.STAR, title: "Starred" }
|
||||
: thread.hasAttachment
|
||||
? { mark: icons.PAPERCLIP, title: "Has an attachment" }
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* The time on a row: when the mail arrived, except where the row is about when it comes back.
|
||||
*
|
||||
* In Snoozed and in the Back group the thread's own date is not what the list is for, so the slot
|
||||
* says the return instead. Whether a moment in the past means late or means it has already come
|
||||
* back is the group's answer, not the time's: in Snoozed it is still waiting and says "Due
|
||||
* yesterday", and in Back it has arrived and says when it was due.
|
||||
*/
|
||||
const timeOf = (thread: ThreadSummary, place: Place): string =>
|
||||
thread.snoozedUntil !== null && (place === "snoozed" || thread.group === "back")
|
||||
? returnTime(thread.snoozedUntil)
|
||||
: rowTime(thread.dateMs);
|
||||
|
||||
/**
|
||||
* The rows kept mounted above and below the window, and the key each one is remembered by.
|
||||
*
|
||||
* Both are out here because they are constants, and a constant written inline is a new object on
|
||||
* every render: react-virtuoso reads them as having changed and remeasures a list that did not.
|
||||
*/
|
||||
const OVERSCAN = { top: 300, bottom: 600 };
|
||||
const keyOf = (_: number, entry: Entry): string => entry.id;
|
||||
|
||||
export function ListColumn() {
|
||||
const place = useMail((s) => s.place);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const labelName = useMail((s) => s.labelName);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const footer = useMail((s) => s.footer);
|
||||
const phase = useMail((s) => s.phase);
|
||||
const paging = useMail((s) => s.paging);
|
||||
const focused = useMail((s) => s.focused);
|
||||
const step = useMail((s) => s.step);
|
||||
const open = useMail((s) => s.open);
|
||||
const goTo = useMail((s) => s.goTo);
|
||||
const loadLabels = useMail((s) => s.loadLabels);
|
||||
const loadMore = useMail((s) => s.loadMore);
|
||||
const selected = useSelection((s) => s.keys);
|
||||
const toggleSelect = useSelection((s) => s.toggle);
|
||||
const clearSelection = useSelection((s) => s.clear);
|
||||
const query = useSearch((s) => s.query);
|
||||
const note = useSearch((s) => s.note);
|
||||
const providerSearched = useSearch((s) => s.providerSearched);
|
||||
const searchPhase = useSearch((s) => s.phase);
|
||||
const askProvider = useSearch((s) => s.askProvider);
|
||||
const more = useSearch((s) => s.more);
|
||||
|
||||
const list = useRef<VirtuosoHandle | null>(null);
|
||||
const scroller = useRef<HTMLElement | null>(null);
|
||||
const [picker, setPicker] = useState<PickerMode | null>(null);
|
||||
const [noting, setNoting] = useState<string | null>(null);
|
||||
|
||||
// The Screener's pill counts senders waiting rather than messages, so it is not part of the page
|
||||
// the list loaded. It is the only count anywhere in this app.
|
||||
const waiting = useScreener((s) => s.cards.length);
|
||||
const loadScreener = useScreener((s) => s.load);
|
||||
|
||||
const entries = useMemo(() => entriesOf(threads), [threads]);
|
||||
const searching = place === "search";
|
||||
const searchOpen = searchPhase !== "off";
|
||||
|
||||
// Whether the mailbox behind an empty list has actually arrived yet. One account when one is
|
||||
// chosen, every account under "All accounts": a fill on any of them is a list that is short.
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const statuses = useSync((s) => s.statuses);
|
||||
const fill = useMemo(
|
||||
() => filling(statuses, accountId ? [accountId] : accounts.map((a) => a.id)),
|
||||
[statuses, accountId, accounts],
|
||||
);
|
||||
|
||||
// The verbs the list owns while it is on screen, acting on the selection when there is one and on
|
||||
// the focused row when there is not. What is not here is what nothing has registered: `r`, `c`
|
||||
// and the rest of writing belong to the next milestone, and an unregistered command does nothing
|
||||
// at all rather than doing half of it.
|
||||
useEffect(() => {
|
||||
const onTargets = (run: (keys: string[]) => void) => () => run(triage.targets());
|
||||
const extendBy = (delta: number) => {
|
||||
const rows = useMail.getState().threads;
|
||||
const at = rows.findIndex((t) => t.key === useMail.getState().focused);
|
||||
if (at === -1) return;
|
||||
const to = at + delta;
|
||||
if (to < 0 || to >= rows.length) return;
|
||||
// The first extension takes the row it started from with it, which is what makes the anchor.
|
||||
if (useSelection.getState().keys.length === 0) useSelection.getState().toggle(rows[at].key);
|
||||
useMail.getState().focus(rows[to].key);
|
||||
useSelection.getState().extend(
|
||||
rows.map((t) => t.key),
|
||||
rows[to].key,
|
||||
);
|
||||
};
|
||||
|
||||
return registerCommands({
|
||||
"select-next": () => step(1),
|
||||
"select-prev": () => step(-1),
|
||||
"open-selection": () => void open(),
|
||||
|
||||
archive: onTargets(triage.archive),
|
||||
"toggle-seen": onTargets(triage.toggleSeen),
|
||||
"toggle-star": onTargets(triage.toggleStar),
|
||||
trash: onTargets(triage.trash),
|
||||
spam: onTargets(triage.spam),
|
||||
"mark-all-seen": () => void triage.markEverythingSeen(),
|
||||
undo: () => void triage.undo(),
|
||||
label: () => setPicker(triage.targets().length > 0 ? "apply" : null),
|
||||
|
||||
"reply-later": onTargets((keys) => void usePiles.getState().toggle(keys, "reply-later")),
|
||||
"set-aside": onTargets((keys) => void usePiles.getState().toggle(keys, "set-aside")),
|
||||
snooze: onTargets((keys) => useSnooze.getState().show(keys, snoozeAnchor())),
|
||||
// A note is one thread's. On a selection of several `y` has nothing to write on, and a key
|
||||
// that would act on nothing does nothing.
|
||||
note: () => {
|
||||
const keys = triage.targets();
|
||||
if (keys.length === 1) setNoting(keys[0]);
|
||||
},
|
||||
// And a merge is two or more, so it is the selection's verb and the focused row is not a
|
||||
// selection of one.
|
||||
merge: () => void mergeThreads(useSelection.getState().keys),
|
||||
|
||||
// `v` is two verbs in docs/keyboard.md: move to a place, which sets the sender's rule, and
|
||||
// move to a label. The first is routing, it belongs to the next milestone and there is no
|
||||
// command behind it yet, so `v` is registered here only where it means the second one.
|
||||
...(place === "label"
|
||||
? { move: () => setPicker(triage.targets().length > 0 ? "move" : null) }
|
||||
: {}),
|
||||
|
||||
select: () => {
|
||||
const key = useMail.getState().focused;
|
||||
if (key) toggleSelect(key);
|
||||
},
|
||||
"select-extend-down": () => extendBy(1),
|
||||
"select-extend-up": () => extendBy(-1),
|
||||
"select-all": () =>
|
||||
useSelection.getState().allFrom(
|
||||
useMail.getState().threads.map((t) => t.key),
|
||||
useMail.getState().focused,
|
||||
),
|
||||
});
|
||||
}, [place, step, open, toggleSelect]);
|
||||
|
||||
// Escape gives the selection back before it gives anything else back, which is the last rung of
|
||||
// the ladder in docs/keyboard.md.
|
||||
useEscapeLayer(selected.length > 0, clearSelection);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLabels();
|
||||
}, [accountId, loadLabels]);
|
||||
|
||||
// The focused row has to be on screen, or `j` walks the list from behind the fold.
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
const index = entries.findIndex((e) => e.kind === "row" && e.id === focused);
|
||||
if (index >= 0) list.current?.scrollIntoView({ index, behavior: "auto" });
|
||||
}, [focused, entries]);
|
||||
|
||||
// Where the list was when search took the stage, and putting it back when search gives it up. The
|
||||
// offset is read the moment the field opens, because by the time the results are in the column
|
||||
// the list that had the scroll is already gone.
|
||||
const parked = useRef(0);
|
||||
const pending = useRef<number | null>(null);
|
||||
const was = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) parked.current = scroller.current?.scrollTop ?? 0;
|
||||
}, [searchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (was.current && !searching) pending.current = parked.current;
|
||||
was.current = searching;
|
||||
}, [searching]);
|
||||
|
||||
// The offset goes back on the list that comes back, which is a different scroller: the results
|
||||
// and the place each mount their own. It rides in as `initialScrollTop` on that mount rather than
|
||||
// being scrolled to afterwards, because a list that has not measured its rows yet has nowhere to
|
||||
// scroll to and would quietly land at the top.
|
||||
useEffect(() => {
|
||||
if (pending.current !== null && entries.length > 0) pending.current = null;
|
||||
}, [entries]);
|
||||
|
||||
// Asked for again whenever the list changed under it, because deciding a sender in the Screener
|
||||
// and archiving a thread in the Inbox both arrive as the same invalidation.
|
||||
useEffect(() => {
|
||||
if (place !== "inbox") return;
|
||||
void loadScreener(accountId);
|
||||
}, [place, accountId, threads, loadScreener]);
|
||||
|
||||
/** The foot of a result list: what the answer covers, and the way to ask for the rest of it. */
|
||||
const searchFoot = useCallback(
|
||||
() => (
|
||||
<div className="search-foot">
|
||||
{note ? <p className="search-note">{note}</p> : null}
|
||||
{/* Every account at once is every provider at once, and search is per account for now. */}
|
||||
{!providerSearched && accountId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
icon={icons.SEARCH}
|
||||
disabled={searchPhase === "searching"}
|
||||
onClick={() => void askProvider()}
|
||||
>
|
||||
Search older mail on Gmail
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
[note, providerSearched, accountId, searchPhase, askProvider],
|
||||
);
|
||||
|
||||
const holdScroller = useCallback((el: HTMLElement | Window | null) => {
|
||||
scroller.current = el as HTMLElement;
|
||||
}, []);
|
||||
|
||||
const endReached = useCallback(
|
||||
() => (searching ? void more() : void loadMore()),
|
||||
[searching, more, loadMore],
|
||||
);
|
||||
|
||||
// The place's quiet line, or, when the next page did not come, the one line that says so and
|
||||
// the way to ask for it again. The rows above are still the rows, so nothing else changes.
|
||||
const listFoot = useCallback(
|
||||
() =>
|
||||
paging === "error" ? (
|
||||
<div className="list-foot" data-state="error">
|
||||
<span>The rest of the list did not arrive.</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => void loadMore()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="list-foot">{footer}</p>
|
||||
),
|
||||
[footer, paging, loadMore],
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
Footer: searching ? searchFoot : footer || paging === "error" ? listFoot : undefined,
|
||||
}),
|
||||
[searching, searchFoot, footer, paging, listFoot],
|
||||
);
|
||||
|
||||
// Everything the rows are drawn from, and nothing else. The list is handed a fresh page of
|
||||
// summaries on every sync pass, so what keeps a row from redrawing is `Row` comparing what it
|
||||
// prints; what keeps this from being called for every row on every keystroke is the identity of
|
||||
// this function, which is why it is not written inline.
|
||||
const itemContent = useCallback(
|
||||
(_: number, entry: Entry) => {
|
||||
if (entry.kind === "head") {
|
||||
return (
|
||||
<div className="list-item">
|
||||
<GroupHead>{entry.label}</GroupHead>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const mark = markOf(entry.thread, place);
|
||||
return (
|
||||
<div className="list-item" data-rule={entry.rule ? "" : undefined}>
|
||||
<Row
|
||||
sender={displayName(entry.thread.from)}
|
||||
address={entry.thread.from.address}
|
||||
brand={isBrand(entry.thread.from)}
|
||||
time={timeOf(entry.thread, place)}
|
||||
subject={entry.thread.subject}
|
||||
snippet={entry.thread.snippet}
|
||||
count={entry.thread.messageCount}
|
||||
note={entry.thread.note ?? undefined}
|
||||
mark={mark?.mark}
|
||||
markTitle={mark?.title}
|
||||
accountHue={accountId === null ? hueOf(entry.thread.accountColor) : undefined}
|
||||
// An ignored thread is never new to you, however many replies it has taken.
|
||||
unread={entry.thread.unseen && !entry.thread.ignored}
|
||||
selected={entry.thread.key === focused}
|
||||
selecting={selected.length > 0}
|
||||
checked={selected.includes(entry.thread.key)}
|
||||
onClick={() => void open(entry.thread.key)}
|
||||
onToggleCheck={() => toggleSelect(entry.thread.key)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[place, accountId, focused, selected, open, toggleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<h1 className="list-title">{place === "label" ? (labelName ?? "Label") : TITLES[place]}</h1>
|
||||
{searching ? <QueryTerms query={query} /> : null}
|
||||
{/* Held back while the account is still arriving: until the crawl finishes and the seed
|
||||
has screened in everyone it already knows, the count is every sender it has met so far,
|
||||
and "Screen 200 new senders" beside a bar that is still filling is a fright about
|
||||
nothing. */}
|
||||
{place === "inbox" && waiting > 0 && fill === null ? (
|
||||
<Pill icon={icons.SHIELD} keycap="6" onClick={() => goTo("screener")}>
|
||||
{`Screen ${waiting} new sender${waiting === 1 ? "" : "s"}`}
|
||||
</Pill>
|
||||
) : null}
|
||||
{/* The third way into Focus & Reply, beside the key and the palette, and the one that is
|
||||
in front of you at the moment you are looking at the pile it is a page over. */}
|
||||
{place === "reply-later" && threads.length > 0 ? (
|
||||
<Pill
|
||||
icon={icons.REPLY}
|
||||
keycap={cap("focus-reply")}
|
||||
onClick={() => runCommand("focus-reply")}
|
||||
>
|
||||
Focus & Reply
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="list list-blank">
|
||||
{phase === "loading" ? null : fill && !searching ? (
|
||||
<Filling fill={fill} />
|
||||
) : (
|
||||
<EmptyState>{EMPTY[place] ?? "Nothing here"}</EmptyState>
|
||||
)}
|
||||
{searching ? searchFoot() : null}
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
className="list"
|
||||
key={searching ? "search" : place}
|
||||
initialScrollTop={pending.current ?? 0}
|
||||
ref={list}
|
||||
scrollerRef={holdScroller}
|
||||
data={entries}
|
||||
computeItemKey={keyOf}
|
||||
endReached={endReached}
|
||||
increaseViewportBy={OVERSCAN}
|
||||
components={components}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Piles />
|
||||
<LabelPicker mode={picker} keys={triage.targets()} onClose={() => setPicker(null)} />
|
||||
<NoteSheet threadKey={noting} onClose={() => setNoting(null)} />
|
||||
<SnoozePicker />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListColumn;
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { isTauri, type Surface } from "../ipc";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { notify } from "../store/useToast";
|
||||
import { Pill } from "../ui";
|
||||
|
||||
/**
|
||||
* One message's body, in a sandboxed iframe.
|
||||
*
|
||||
* The frame is here for style isolation. Mail HTML sets global styles aggressively, and rendering a
|
||||
* newsletter in the app's own document lets it restyle the list beside it.
|
||||
*
|
||||
* Scripts cannot run in it. `sandbox` without `allow-scripts` disables them; the content security
|
||||
* policy in src-tauri/tauri.conf.json is `script-src 'self'`, which independently blocks inline
|
||||
* scripts and inline event handlers; and the sanitiser in Rust has already removed them. Three
|
||||
* layers, and the sanitiser is only the first.
|
||||
*
|
||||
* `sandbox=""` would be tighter and it is what an earlier draft called for, but it makes the
|
||||
* frame's origin opaque, and a document the parent cannot reach is a document the parent cannot
|
||||
* measure. There is then no way to size the frame to its content and no way to catch a click on a
|
||||
* link. `allow-same-origin` keeps every other restriction, forms and top navigation included.
|
||||
*
|
||||
* So the parent does the two jobs the frame cannot. A ResizeObserver on the frame's
|
||||
* documentElement sets the height, so the pane scrolls as one page rather than each message
|
||||
* carrying its own scrollbar. A click listener on the frame's document catches anchors, calls
|
||||
* preventDefault and hands the URL to the system browser: the href it opens is the one Rust
|
||||
* already cleaned.
|
||||
*
|
||||
* The token stylesheet has to be injected as well, or a message renders in Times. That stylesheet
|
||||
* is `public/message.css`, and the values it reads are set as custom properties on the frame's root
|
||||
* from the app's own computed tokens, so a body follows the theme and the text size without either
|
||||
* side holding a colour of its own.
|
||||
*
|
||||
* It is fetched once and written into the document rather than linked from it, because a linked
|
||||
* sheet arrives after the frame has already painted and a page of prose that resets its line breaks
|
||||
* a moment after you started reading is worse than one that arrives a beat late. Same reasoning as
|
||||
* `font-display: block` in the shared fonts sheet.
|
||||
*/
|
||||
export interface MessageBodyProps {
|
||||
html: string;
|
||||
/** Transactional mail, which reads in the interface face because it is data rather than prose. */
|
||||
plain?: boolean;
|
||||
/**
|
||||
* Which surface this body reads on, decided in Rust from what the sender painted.
|
||||
*
|
||||
* `paper` pins the light palette in both themes, for a message that laid out a page of its own:
|
||||
* a newsletter's wash behind a card, a receipt's tinted wrapper. `theme` is everything else, and
|
||||
* everything else is most mail. Rust has already taken the author colours that would be
|
||||
* unreadable on our own paper off a `theme` body, so what is left inherits ours.
|
||||
*/
|
||||
surface?: Surface;
|
||||
}
|
||||
|
||||
/**
|
||||
* A body the mirror does not have yet, in the shape of the paragraph it is going to be.
|
||||
*
|
||||
* `thread_view` is a local read and never waits on the network, so a message whose body has not
|
||||
* been fetched comes back with `bodyPending` set and nothing to render. Empty paper reads as a
|
||||
* message with nothing in it; three bars that breathe read as a message on its way, which is the
|
||||
* one thing Mailspring does that this app was asked to do too.
|
||||
*/
|
||||
export function BodySkeleton() {
|
||||
return (
|
||||
<div className="msg-pending" role="status" aria-label="Fetching this message">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same slot once the fetch has come back with nothing in it: one line, and the way to ask
|
||||
* again. The alternative was the bars above breathing until the thread was closed, because a
|
||||
* provider refusing every body is a count of zero to the command and not a failure.
|
||||
*/
|
||||
export function BodyMissing({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="msg-pending" data-state="error" role="status">
|
||||
Could not fetch this message.
|
||||
<Pill tone="quiet" onClick={onRetry}>
|
||||
Try again
|
||||
</Pill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The small set a message body needs. Anything else is the sender's business, not ours. */
|
||||
const TOKENS: Record<string, string> = {
|
||||
"--m-font-text": "--font-heading",
|
||||
"--m-font-ui": "--font-ui",
|
||||
"--m-size": "--body-size",
|
||||
"--m-size-plain": "--t-3",
|
||||
"--m-ink": "--ink",
|
||||
"--m-faint": "--ink-faint",
|
||||
"--m-line": "--line",
|
||||
"--m-wash": "--accent-wash",
|
||||
"--m-measure": "--measure",
|
||||
"--m-paper": "--paper",
|
||||
};
|
||||
|
||||
/** The same set for a message that painted its own page, pinned to the light palette in both. */
|
||||
const PAPER_TOKENS: Record<string, string> = {
|
||||
...TOKENS,
|
||||
"--m-ink": "--message-ink",
|
||||
"--m-faint": "--message-faint",
|
||||
"--m-line": "--message-line",
|
||||
"--m-wash": "--message-wash",
|
||||
"--m-paper": "--message-paper",
|
||||
};
|
||||
|
||||
/** Fetched once for the whole app. The browser would cache it anyway; this caches the parse too. */
|
||||
let sheet: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* The same text once it has arrived, readable without waiting.
|
||||
*
|
||||
* A promise, however warm, is still a render with nothing to put in the frame, and a frame with
|
||||
* nothing in it is a navigation: every message body after the first would load an empty document
|
||||
* and then load itself over the top of it. One thread of eight messages is eight of those.
|
||||
*/
|
||||
let sheetText: string | null = null;
|
||||
|
||||
function messageCss(): Promise<string> {
|
||||
sheet ??= fetch("/message.css")
|
||||
.then((response) => response.text())
|
||||
.catch(() => "");
|
||||
return sheet.then((text) => {
|
||||
sheetText = text;
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's values, as a rule the frame's own stylesheet can read.
|
||||
*
|
||||
* A rule rather than a style attribute, because a font stack is `"Literata", Georgia, serif` and
|
||||
* the first of those quotes ends an attribute and silently takes every property after it with it.
|
||||
*
|
||||
* `--m-color-scheme` is the one value here that is not a token lookup, because it is not a colour:
|
||||
* it is what the document inside the frame tells the browser about itself. On a pinned page it is
|
||||
* `only light`, and that half is the one nobody ships. Without it a body we have painted white
|
||||
* still gets the user agent's dark canvas and dark form controls under it whenever the machine is
|
||||
* in dark mode, and white text on white is the result. On the theme it is simply ours.
|
||||
*/
|
||||
function rootRule(surface: Surface, theme: string): string {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const pinned = surface === "paper";
|
||||
const values = Object.entries(pinned ? PAPER_TOKENS : TOKENS)
|
||||
.map(([name, token]) => `${name}:${computed.getPropertyValue(token).trim()}`)
|
||||
.join(";");
|
||||
return `:root{${values};--m-color-scheme:${pinned ? "only light" : theme}}`;
|
||||
}
|
||||
|
||||
export function MessageBody({ html, plain, surface = "theme" }: MessageBodyProps) {
|
||||
const frame = useRef<HTMLIFrameElement | null>(null);
|
||||
const theme = useTheme((s) => s.theme);
|
||||
const [css, setCss] = useState<string | null>(sheetText);
|
||||
|
||||
useEffect(() => {
|
||||
if (sheetText !== null) return;
|
||||
let live = true;
|
||||
void messageCss().then((text) => {
|
||||
if (live) setCss(text);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Read once per palette rather than once per property, and again when the palette changes under
|
||||
// an open thread.
|
||||
const tokens = useMemo(() => rootRule(surface, theme), [theme, surface]);
|
||||
|
||||
const srcdoc = useMemo(
|
||||
() =>
|
||||
css === null
|
||||
? ""
|
||||
: `<!doctype html><html><head><meta charset="utf-8"><style>${tokens}\n${css}</style></head><body${plain ? " data-plain" : ""}>${html}</body></html>`,
|
||||
[css, html, plain, tokens],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = frame.current;
|
||||
if (!el || !srcdoc) return;
|
||||
let observer: ResizeObserver | null = null;
|
||||
// Which document the listener went on. Changing `srcdoc` leaves the old one in place for a
|
||||
// moment, so taking it off again means remembering the one it was added to.
|
||||
let attached: Document | null = null;
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
if (isTauri) void openUrl(href).catch((e) => notify(`Could not open that link: ${String(e)}`));
|
||||
else window.open(href, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
/** Whether there was a document there to take. */
|
||||
const attach = (): boolean => {
|
||||
const doc = el.contentDocument;
|
||||
// A document whose parser has not reached the body yet has nothing to measure and nothing to
|
||||
// observe, and `ResizeObserver.observe(null)` throws hard enough to take the screen with it.
|
||||
if (!doc?.body) return false;
|
||||
observer?.disconnect();
|
||||
// The height is written on the element rather than held in state: a body that reflows while
|
||||
// its images decode would otherwise re-render the whole thread on every frame.
|
||||
const size = () => {
|
||||
// The body rather than the documentElement. An iframe's root element never reports less
|
||||
// than the frame's own height, so measuring it floors every short message at whatever the
|
||||
// frame happened to be and leaves a band of blank paper under a two line note.
|
||||
//
|
||||
// Zero is not a height, it is a document that has not laid out yet, and writing it back
|
||||
// used to end the conversation: the root element is bounded by the frame, `html` here is
|
||||
// `overflow: hidden`, and a frame set to a pixel can never change size again, so the
|
||||
// observer was never asked a second time and the message stayed a sliver. It only ever
|
||||
// happened on a loaded machine, which is what made it look like a flaky test.
|
||||
const height = doc.body.scrollHeight;
|
||||
if (height > 0) el.style.height = `${height}px`;
|
||||
};
|
||||
observer = new ResizeObserver(size);
|
||||
observer.observe(doc.documentElement);
|
||||
// And the body, because that is the box that grows when the content finally arrives.
|
||||
observer.observe(doc.body);
|
||||
size();
|
||||
attached?.removeEventListener("click", onClick);
|
||||
doc.addEventListener("click", onClick);
|
||||
attached = doc;
|
||||
return true;
|
||||
};
|
||||
|
||||
// The load is the backstop and not the moment. It waits on the two faces the sheet declares,
|
||||
// which are `font-display: block` and can be a whole second on a cold cache, and a message that
|
||||
// is not sized until then is a message that jumps under the reader. The body is laid out long
|
||||
// before that, so the frame is taken the moment it has one and on the frame after this one if
|
||||
// it does not yet. Reading a readyState instead would not help: every document a frame passes
|
||||
// through on the way to this one reports itself complete as readily as this one does.
|
||||
let waiting = 0;
|
||||
const take = () => {
|
||||
if (!attach()) waiting = requestAnimationFrame(take);
|
||||
};
|
||||
|
||||
el.addEventListener("load", attach);
|
||||
take();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(waiting);
|
||||
el.removeEventListener("load", attach);
|
||||
observer?.disconnect();
|
||||
attached?.removeEventListener("click", onClick);
|
||||
};
|
||||
}, [srcdoc]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="msg-frame"
|
||||
ref={frame}
|
||||
title="Message"
|
||||
// Also on the element, because what a child document is told about `prefers-color-scheme` is
|
||||
// decided by whatever embeds it and not by its own root. pane.css is where that is written.
|
||||
data-surface={surface}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={srcdoc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageBody;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Key, Popover } from "../ui";
|
||||
import { isRegistered, runCommand } from "../keys/commands";
|
||||
import { keysFor, normalizeCombo, type CommandId } from "../keys/bindings";
|
||||
import { comboOf, useKeyContext } from "../keys/keymap";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { cap } from "./format";
|
||||
import "./more.css";
|
||||
|
||||
/**
|
||||
* `.`. The rest of the thread's verbs, behind the last button on the pane's bar.
|
||||
*
|
||||
* The bar carries the five a hand reaches for and this carries the others, in the order
|
||||
* docs/keyboard.md lists them, each printed with its key. A row runs the command and nothing else,
|
||||
* so the row, the key and the palette are one code path and cannot come to mean different things.
|
||||
*
|
||||
* A verb nobody owns right now is left out rather than greyed: `v` means a label move only in a
|
||||
* label list, `Cmd+U` is the Feed's, and a menu that showed them dimmed everywhere else would be a
|
||||
* menu you had to read twice. The registry is asked when the menu opens, which is the moment the
|
||||
* answer is about.
|
||||
*/
|
||||
|
||||
interface RowFlags {
|
||||
unseen: boolean;
|
||||
starred: boolean;
|
||||
trashed: boolean;
|
||||
spam: boolean;
|
||||
}
|
||||
|
||||
interface MoreVerb {
|
||||
command: CommandId;
|
||||
label: string | ((flags: RowFlags) => string);
|
||||
}
|
||||
|
||||
const VERBS: readonly MoreVerb[] = [
|
||||
{ command: "reply-all", label: "Reply all" },
|
||||
{ command: "forward", label: "Forward" },
|
||||
{ command: "toggle-seen", label: (f) => (f.unseen ? "Mark seen" : "Mark unseen") },
|
||||
{ command: "toggle-star", label: (f) => (f.starred ? "Unstar" : "Star") },
|
||||
{ command: "note", label: "Add a note" },
|
||||
{ command: "contact-card", label: "Contact card" },
|
||||
{ command: "label", label: "Label" },
|
||||
{ command: "move", label: "Move to a label" },
|
||||
{ command: "trash", label: (f) => (f.trashed ? "Put back" : "Trash") },
|
||||
{ command: "spam", label: (f) => (f.spam ? "Not spam" : "Mark as spam") },
|
||||
];
|
||||
|
||||
export interface MoreMenuProps {
|
||||
open: boolean;
|
||||
/** The More button, which is what the menu hangs from. */
|
||||
anchor: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MoreMenu({ open, anchor, onClose }: MoreMenuProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<Popover open anchor={anchor} onClose={onClose} placement="bottom-end" width={240} label="More">
|
||||
<MoreList onClose={onClose} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list itself, as its own component because the popover draws its children a frame after it
|
||||
* is asked to open, once it knows where the anchor is. Focusing the first row and asking the
|
||||
* registry both belong to the moment the rows exist, which is this component's mount.
|
||||
*/
|
||||
function MoreList({ onClose }: { onClose: () => void }) {
|
||||
const list = useRef<HTMLUListElement | null>(null);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
const thread = useMail((s) => s.thread);
|
||||
|
||||
// Every toggle here says which way it is about to go rather than "toggle". The row is asked
|
||||
// first because the verbs patch the row and not the view, so it is the row that is current; the
|
||||
// view answers for a thread opened from somewhere with no row behind it.
|
||||
const flags = useMemo<RowFlags>(() => {
|
||||
const row = threads.find((t) => t.key === openKey);
|
||||
return {
|
||||
unseen: row?.unseen ?? false,
|
||||
starred: row?.starred ?? thread?.starred ?? false,
|
||||
trashed: row?.trashed ?? thread?.trashed ?? false,
|
||||
spam: row?.spam ?? thread?.spam ?? false,
|
||||
};
|
||||
}, [threads, openKey, thread]);
|
||||
|
||||
const shown = useMemo(() => VERBS.filter((v) => isRegistered(v.command)), []);
|
||||
|
||||
// In front of the pane, so `u` below means the row here and not the list's key behind it.
|
||||
useKeyContext("overlay");
|
||||
|
||||
useEffect(() => {
|
||||
list.current?.querySelector<HTMLButtonElement>(".more-option")?.focus();
|
||||
}, []);
|
||||
|
||||
const choose = (command: CommandId) => {
|
||||
onClose();
|
||||
runCommand(command);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const step = (delta: number) => {
|
||||
const rows = [...(list.current?.querySelectorAll<HTMLButtonElement>(".more-option") ?? [])];
|
||||
if (rows.length === 0) return;
|
||||
const at = rows.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = at === -1 ? (delta > 0 ? 0 : rows.length - 1) : (at + delta + rows.length) % rows.length;
|
||||
rows[next].focus();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.isComposing || e.key === "Escape") return;
|
||||
const combo = comboOf(e);
|
||||
if (combo === "ArrowDown" || combo === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
step(combo === "ArrowDown" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
// The key that opened it closes it.
|
||||
if (combo === ".") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// A verb's own key works from inside the menu, because the menu is where somebody learns it.
|
||||
const verb = shown.find((v) => keysFor(v.command).some((k) => normalizeCombo(k) === combo));
|
||||
if (!verb) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
choose(verb.command);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="more-menu">
|
||||
{shown.length === 0 ? (
|
||||
<p className="more-empty">Nothing more to do here</p>
|
||||
) : (
|
||||
<ul className="more-list" role="menu" ref={list}>
|
||||
{shown.map((verb) => {
|
||||
const key = cap(verb.command);
|
||||
return (
|
||||
<li key={verb.command} role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="more-option"
|
||||
onClick={() => choose(verb.command)}
|
||||
>
|
||||
<span className="more-label">
|
||||
{typeof verb.label === "function" ? verb.label(flags) : verb.label}
|
||||
</span>
|
||||
{key ? <Key size="sm">{key}</Key> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Sheet, Toggle } from "../ui";
|
||||
import { askForNotifications, notifyTest } from "../api/notifications";
|
||||
import { startFresh } from "../api/threads";
|
||||
import { undoToken } from "../api/undo";
|
||||
import type { Place } from "../ipc";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { notify } from "../store/useToast";
|
||||
import { NOTIFY_PLACES, placesSentence, withPlace } from "./notifyPlaces";
|
||||
import "./onboarding.css";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** A week by default, which is the age at which mail stops being something you are still on. */
|
||||
const AGES = [
|
||||
{ id: "day", label: "a day", days: 1 },
|
||||
{ id: "week", label: "a week", days: 7 },
|
||||
{ id: "month", label: "a month", days: 30 },
|
||||
{ id: "quarter", label: "three months", days: 90 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Which accounts have already been shown this panel on this machine.
|
||||
*
|
||||
* A device fact rather than a roaming one, and deliberately not in the state database with the
|
||||
* decisions that follow a person around. The panel is about the sync that just finished here: it
|
||||
* counts what arrived on this disk and offers a bulk write against it. A second machine syncing
|
||||
* the same account has that story to tell for the first time too, and a flag that roamed would
|
||||
* leave its first run silent about a mailbox it has just filled from scratch.
|
||||
*/
|
||||
const KEY = "marginmail-onboarded";
|
||||
|
||||
function onboarded(): string[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
||||
return Array.isArray(stored) ? stored.filter((id) => typeof id === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function remember(accountId: string): void {
|
||||
const all = onboarded();
|
||||
if (all.includes(accountId)) return;
|
||||
localStorage.setItem(KEY, JSON.stringify([...all, accountId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first run panel, over the Inbox it is talking about.
|
||||
*
|
||||
* It says what the pass over senders did, offers the one bulk write this app ever proposes, and
|
||||
* asks once whether new mail should say so. Skipping is Done: nothing here has to be answered, and
|
||||
* a panel that had to be dismissed in a particular way would be a wizard.
|
||||
*/
|
||||
export function Onboarding() {
|
||||
const onboarding = useAccounts((s) => s.onboarding);
|
||||
const dismiss = useAccounts((s) => s.dismissOnboarding);
|
||||
const [age, setAge] = useState("week");
|
||||
// `marking` is the bulk write on its way, which holds the panel open until it answers.
|
||||
const [phase, setPhase] = useState<"idle" | "marking" | "error">("idle");
|
||||
// Held here rather than read from the store, so the switches move the moment they are pressed
|
||||
// and stay put while the save is on its way.
|
||||
const [places, setPlaces] = useState<Place[]>(
|
||||
() => useSettings.getState().settings?.notifyPlaces ?? [],
|
||||
);
|
||||
const [asked, setAsked] = useState(false);
|
||||
|
||||
const accountId = onboarding?.accountId ?? null;
|
||||
const shown = accountId !== null && !onboarded().includes(accountId);
|
||||
|
||||
// An account that has already had its first run here has nothing to be told again, so the panel
|
||||
// never mounts rather than flashing and closing.
|
||||
useEffect(() => {
|
||||
if (accountId && onboarded().includes(accountId)) dismiss();
|
||||
}, [accountId, dismiss]);
|
||||
|
||||
if (!onboarding || !shown) return null;
|
||||
|
||||
// Whichever way the panel ends, the tour follows it: this is the one moment the app has somebody's
|
||||
// attention and almost nothing here works the way their last mail client did. It follows every
|
||||
// account rather than only the first, because the panel it follows is per account too and a
|
||||
// second mailbox on a shared machine is somebody else's first look at the app. Skip is the first
|
||||
// control on it and Escape is the same answer.
|
||||
const done = () => {
|
||||
remember(onboarding.accountId);
|
||||
dismiss();
|
||||
useOverlays.getState().show("tour");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const days = AGES.find((a) => a.id === age)?.days ?? 7;
|
||||
setPhase("marking");
|
||||
try {
|
||||
const undo = await startFresh(onboarding.accountId, Date.now() - days * DAY_MS);
|
||||
void useMail.getState().load();
|
||||
notify(undo.label, {
|
||||
label: "Undo",
|
||||
keycap: "z",
|
||||
run: () => void undoToken(undo.token).then(() => useMail.getState().load()),
|
||||
});
|
||||
setPhase("idle");
|
||||
done();
|
||||
} catch (e) {
|
||||
setPhase("error");
|
||||
notify(`Could not mark that mail as seen: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const marking = phase === "marking";
|
||||
|
||||
const setPlace = async (place: Place, on: boolean) => {
|
||||
const next = withPlace(places, place, on);
|
||||
setPlaces(next);
|
||||
const store = useSettings.getState();
|
||||
// The settings may not have been read yet: this panel can be the first thing to want them.
|
||||
if (!store.settings) await store.load();
|
||||
await store.save(on ? { notifyPlaces: next, notifications: true } : { notifyPlaces: next });
|
||||
|
||||
// The first time anything is turned on is the moment to find out whether the system will let
|
||||
// us: where that is a dialog, the sample follows once it is allowed. Once per panel: the answer
|
||||
// does not change between switches.
|
||||
if (on && !asked) {
|
||||
setAsked(true);
|
||||
if ((await askForNotifications()) === "granted") {
|
||||
notifyTest().catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
title="You are set up"
|
||||
busy={marking}
|
||||
onClose={done}
|
||||
foot={
|
||||
<>
|
||||
<Button disabled={marking} onClick={() => void run()}>
|
||||
{marking ? "Marking" : "Start fresh"}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={marking} onClick={done}>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="onboard" data-phase={phase}>
|
||||
<p className="onboard-lead">
|
||||
<b>{onboarding.screenedIn.toLocaleString()} senders</b> were screened in already, because
|
||||
you have written to them or they are in your contacts.
|
||||
</p>
|
||||
<p className="onboard-sub">
|
||||
From here on, anyone new waits in the Screener until you say where their mail goes. Nobody
|
||||
is told either way.
|
||||
</p>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Start fresh</span>
|
||||
<p className="onboard-sub">
|
||||
Optional. Mark older mail as seen, so only what is recent reads as new. It is the only
|
||||
bulk write this app ever proposes, and it is reversible for seven days.
|
||||
</p>
|
||||
<div className="choice">
|
||||
<span className="choice-label">Older than</span>
|
||||
{AGES.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="choice-option"
|
||||
data-on={option.id === age ? "" : undefined}
|
||||
aria-pressed={option.id === age}
|
||||
disabled={marking}
|
||||
onClick={() => setAge(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Tell me when new mail arrives</span>
|
||||
<p className="onboard-sub">
|
||||
Optional, and off everywhere until you say. A thread or a person can be turned on later
|
||||
with ⇧N or from the contact card; this is what everything else falls back to.
|
||||
</p>
|
||||
<div className="onboard-toggles">
|
||||
{NOTIFY_PLACES.map((place) => (
|
||||
<Toggle
|
||||
key={place.id}
|
||||
checked={places.includes(place.id)}
|
||||
label={place.label}
|
||||
note={place.note}
|
||||
onChange={(on) => void setPlace(place.id, on)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="onboard-sub onboard-quiet">{placesSentence(places)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Onboarding;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from "react";
|
||||
import { icons, Icon, Key } from "../ui";
|
||||
import type { Pile, Place, ThreadSummary } from "../ipc";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useSelection } from "../store/useSelection";
|
||||
import { ActionBar } from "./ActionBar";
|
||||
import { displayName } from "./format";
|
||||
import "./piles.css";
|
||||
|
||||
/**
|
||||
* The two stacks at the foot of the list: Reply later on the left, Set aside on the right.
|
||||
*
|
||||
* A pile is its own query rather than a slice of the list above it, because a piled thread is
|
||||
* exactly the thread the Inbox has stopped showing. Each stack prints its label, its key, the top
|
||||
* thread's subject and who it is from, and an edge or two of the cards underneath so it reads as a
|
||||
* stack rather than as a single card. An empty pile is a dashed outline with its label.
|
||||
*
|
||||
* The other thing in this footprint is the selection's action bar, which takes the whole of it
|
||||
* while a selection exists.
|
||||
*/
|
||||
const PILES: { pile: Pile; place: Place; label: string; keycap: string; icon: string }[] = [
|
||||
{ pile: "reply-later", place: "reply-later", label: "Reply later", keycap: "4", icon: icons.CLOCK },
|
||||
{ pile: "set-aside", place: "set-aside", label: "Set aside", keycap: "5", icon: icons.SET_ASIDE },
|
||||
];
|
||||
|
||||
/**
|
||||
* Who the pile is from: the top thread's sender, and how many more are underneath it.
|
||||
*
|
||||
* The count is here rather than as a number in the corner because it is the only place in the app
|
||||
* where one would be right: a pile is a thing with a depth, and how deep it is is what the stack
|
||||
* is drawing.
|
||||
*/
|
||||
function whoOf(top: ThreadSummary, count: number): string {
|
||||
const name = displayName(top.from);
|
||||
if (count <= 1) return name;
|
||||
return `${name}, and ${count - 1} more`;
|
||||
}
|
||||
|
||||
export function Piles() {
|
||||
const goTo = useMail((s) => s.goTo);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const rows = useMail((s) => s.threads);
|
||||
const selected = useSelection((s) => s.keys);
|
||||
const threads = usePiles((s) => s.threads);
|
||||
const load = usePiles((s) => s.load);
|
||||
|
||||
// Asked for again whenever the list changed under it, because piling a thread in the Inbox and
|
||||
// undoing an archive both arrive as the same invalidation.
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [accountId, rows, load]);
|
||||
|
||||
// The bar takes the whole footprint rather than sitting beside the piles. Two stacks and a row of
|
||||
// verbs in 420px is neither, and what a selection wants from this corner is the verbs.
|
||||
if (selected.length > 0) return <ActionBar />;
|
||||
|
||||
return (
|
||||
<div className="piles">
|
||||
{PILES.map((pile) => {
|
||||
const held = threads[pile.pile];
|
||||
const top = held[0];
|
||||
return (
|
||||
<button
|
||||
key={pile.place}
|
||||
type="button"
|
||||
className="pile"
|
||||
data-empty={top ? undefined : ""}
|
||||
onClick={() => goTo(pile.place)}
|
||||
>
|
||||
{held.length > 2 ? <span className="pile-edge" data-depth="2" /> : null}
|
||||
{held.length > 1 ? <span className="pile-edge" data-depth="1" /> : null}
|
||||
<span className="pile-card">
|
||||
<span className="pile-top">
|
||||
<Icon d={pile.icon} size={11} />
|
||||
{pile.label}
|
||||
<Key size="sm">{pile.keycap}</Key>
|
||||
</span>
|
||||
{top ? (
|
||||
<>
|
||||
<span className="pile-subject">{top.subject}</span>
|
||||
<span className="pile-who">{whoOf(top, held.length)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Piles;
|
||||
@@ -0,0 +1,946 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, AvatarStack, Banner, Button, EmptyState, Field, icons, Pill, Sheet } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
import { attachmentOpen, messageShowImages } from "../api/messages";
|
||||
import { noteAdd } from "../api/notes";
|
||||
import { threadMerge, threadRename, threadUnmerge } from "../api/threads";
|
||||
import { undoToken } from "../api/undo";
|
||||
import type { CommandId } from "../keys/bindings";
|
||||
import type { MessageView, Person, Surface, ThreadSummary, Undo } from "../ipc";
|
||||
import { useAccounts } from "../store/useAccounts";
|
||||
import { useCompose, type ComposerKind } from "../store/useCompose";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { usePiles } from "../store/usePiles";
|
||||
import { useSelection } from "../store/useSelection";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { useSnooze } from "../store/useSnooze";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { notify } from "../store/useToast";
|
||||
import { LabelPicker, type PickerMode } from "./ActionBar";
|
||||
import { ReplyBox, SendingLine } from "./Compose";
|
||||
import { InviteCard } from "./InviteCard";
|
||||
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody";
|
||||
import { MoreMenu } from "./MoreMenu";
|
||||
import { SnoozePicker, snoozeAnchor } from "./SnoozePicker";
|
||||
import * as triage from "./triage";
|
||||
import {
|
||||
cap,
|
||||
displayName,
|
||||
fileKind,
|
||||
fileSize,
|
||||
isBrand,
|
||||
messageTime,
|
||||
participantLine,
|
||||
previewOf,
|
||||
} from "./format";
|
||||
import "./pane.css";
|
||||
|
||||
// The three decisions that are read here and taken from anywhere: a note, a rename and a merge.
|
||||
//
|
||||
// They live with the pane because the pane is where all three are read: the note is a block after
|
||||
// its message, the rename is the line beside the subject, and the merge is the banner over the
|
||||
// thread. The list imports the two panels below for the same reason it imports the label picker
|
||||
// from the action bar, which is that a verb belongs with what it draws rather than with what
|
||||
// happens to have pressed it.
|
||||
|
||||
/** The toast a hidden action leaves behind, carrying the token that reverses that one action. */
|
||||
function acknowledge(undo: Undo): void {
|
||||
notify(undo.label, {
|
||||
label: "Undo",
|
||||
keycap: "z",
|
||||
run: () => {
|
||||
void undoToken(undo.token)
|
||||
.then(() => void useMail.getState().load())
|
||||
.catch((e) => notify(`Could not undo that: ${e}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The open thread, asked for again.
|
||||
*
|
||||
* A note, a rename and a merge all change what the pane is drawing, and the invalidation the
|
||||
* backend emits refreshes the list rather than the thread in front of you. Nothing happens when
|
||||
* the thread is not the one open, which is what makes `y` from the list safe.
|
||||
*/
|
||||
function reopen(key: string): void {
|
||||
if (useMail.getState().openKey === key) void useMail.getState().open(key);
|
||||
}
|
||||
|
||||
/** `y`. A note is one thread's, it shows itself in the row and in the pane, and it says nothing. */
|
||||
export async function saveNote(threadKey: string, body: string): Promise<void> {
|
||||
const text = body.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await noteAdd(threadKey, text);
|
||||
reopen(threadKey);
|
||||
} catch (e) {
|
||||
notify(`That did not go through: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A name of your own for a thread. No toast: the new name is in the list and the pane the moment
|
||||
* it lands, and "renamed · was …" beside the subject is the receipt. `z` still takes it back.
|
||||
*/
|
||||
export async function renameThread(key: string, name: string | null): Promise<void> {
|
||||
try {
|
||||
await threadRename(key, name);
|
||||
reopen(key);
|
||||
} catch (e) {
|
||||
notify(`That did not go through: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** `g`. Two or more threads become one, named after the first, with the banner saying where from. */
|
||||
export async function mergeThreads(keys: string[]): Promise<void> {
|
||||
if (keys.length < 2) return;
|
||||
const mail = useMail.getState();
|
||||
// The merged thread takes the first key, so the rest are the rows that disappear into it.
|
||||
const taken = mail.take(keys.slice(1));
|
||||
mail.patch([keys[0]], { merged: true });
|
||||
useSelection.getState().clear();
|
||||
try {
|
||||
const undo = await threadMerge(keys, null);
|
||||
// The count and the participants are the merge's answer rather than a guess this side could
|
||||
// have made, so the row is asked for again once the write has landed.
|
||||
void useMail.getState().load();
|
||||
reopen(keys[0]);
|
||||
acknowledge(undo);
|
||||
} catch (e) {
|
||||
useMail.getState().untake(taken);
|
||||
notify(`That did not go through: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner's verb. The re-read is awaited, unlike the merge's: the banner this was pressed on is
|
||||
* up until the view without `mergedFrom` lands and its button is saying it is working until then,
|
||||
* so settling any earlier would put "Unmerge" back on a strip that is about to go.
|
||||
*/
|
||||
export async function unmergeThread(key: string): Promise<void> {
|
||||
try {
|
||||
const undo = await threadUnmerge(key);
|
||||
void useMail.getState().load();
|
||||
acknowledge(undo);
|
||||
if (useMail.getState().openKey === key) await useMail.getState().open(key);
|
||||
} catch (e) {
|
||||
notify(`That did not go through: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface VerbSpec {
|
||||
command: CommandId;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar of verbs, in the order the hand reaches for them: the four that move a thread on the
|
||||
* left, and Archive on the right where it cannot be hit by accident.
|
||||
*
|
||||
* Every one of them goes through the command registry rather than calling anything directly, which
|
||||
* is what keeps the button and the key one code path. Reply all and Forward are not in the bar and
|
||||
* are not missing: `a` and `f` open the same box this button does with different recipients in it,
|
||||
* and three buttons for one verb is a bar you have to read.
|
||||
*/
|
||||
const LEAD: VerbSpec[] = [
|
||||
{ command: "reply", label: "Reply", icon: icons.REPLY },
|
||||
{ command: "reply-later", label: "Reply later", icon: icons.CLOCK },
|
||||
{ command: "set-aside", label: "Set aside", icon: icons.SET_ASIDE },
|
||||
{ command: "snooze", label: "Snooze", icon: icons.SNOOZE },
|
||||
];
|
||||
|
||||
/** Paper Trail is not a place you answer from, and it has somewhere of its own to send a thread. */
|
||||
const TRAIL_LEAD: VerbSpec[] = LEAD.filter((v) => v.command !== "reply-later");
|
||||
|
||||
function PaneBar({ notify }: { notify: boolean }) {
|
||||
const place = useMail((s) => s.place);
|
||||
const lead = place === "paper-trail" ? TRAIL_LEAD : LEAD;
|
||||
const [more, setMore] = useState(false);
|
||||
const moreButton = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// `.` and the button are one verb, so the key is registered by the bar that draws the button and
|
||||
// for exactly as long as there is a thread for the menu to be about.
|
||||
useEffect(() => registerCommands({ more: () => setMore((was) => !was) }), []);
|
||||
|
||||
return (
|
||||
<div className="pane-bar">
|
||||
{lead.map((verb) => (
|
||||
<Verb key={verb.command} {...verb} />
|
||||
))}
|
||||
<span className="pane-gap" />
|
||||
{place === "paper-trail" ? (
|
||||
<Verb command="move" label="Move to Inbox" icon={icons.INBOX} />
|
||||
) : null}
|
||||
{notify ? <Verb command="notify" label="Notify" icon={icons.BELL} /> : null}
|
||||
<Verb command="archive" label="Archive" icon={icons.ARCHIVE} />
|
||||
<Button
|
||||
ref={moreButton}
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.MORE}
|
||||
title={`More (${cap("more")})`}
|
||||
label="More"
|
||||
active={more}
|
||||
onClick={() => setMore((was) => !was)}
|
||||
/>
|
||||
<MoreMenu open={more} anchor={moreButton.current} onClose={() => setMore(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an address is one of this app's own, which is what "to you" and "and you" both mean. */
|
||||
function useIsMe(): (person: Person) => boolean {
|
||||
const accounts = useAccounts((s) => s.accounts);
|
||||
const mine = useMemo(() => new Set(accounts.map((a) => a.email.toLowerCase())), [accounts]);
|
||||
return (person: Person) => mine.has(person.address.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread you clicked, on the frame you clicked it, before its view has arrived.
|
||||
*
|
||||
* Everything here comes off the row the list is already holding: the subject, the sender, the
|
||||
* people and the time. Only the bodies need a read, so only the bodies wait, and the alternative
|
||||
* this replaces was leaving the last thread you read on screen under the new row's selection.
|
||||
*
|
||||
* `data-opening` is the state, on the pane rather than in it, so a test can ask which of the two
|
||||
* heads it is looking at without either of them having to look different.
|
||||
*/
|
||||
function OpeningPane({ summary }: { summary: ThreadSummary }) {
|
||||
const isMe = useIsMe();
|
||||
const others = summary.participants.filter((p) => !isMe(p));
|
||||
return (
|
||||
<section className="pane" data-opening="">
|
||||
<PaneBar notify={summary.notify} />
|
||||
<div className="thread">
|
||||
<div className="thread-inner">
|
||||
<div className="thread-head">
|
||||
<h1 className="thread-subject" title={summary.subject}>
|
||||
{/* The same class as the rename control the loaded head puts here, because it is
|
||||
the same slot: the clamp and the face belong to the subject, not to the button
|
||||
that has not been drawn yet. */}
|
||||
<span className="thread-name">{summary.subject}</span>
|
||||
</h1>
|
||||
<div className="thread-meta">
|
||||
<AvatarStack
|
||||
people={others.map((p) => ({
|
||||
name: displayName(p),
|
||||
address: p.address,
|
||||
brand: isBrand(p),
|
||||
}))}
|
||||
/>
|
||||
<span className="thread-people">{participantLine(others.map(displayName), false)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{`${summary.messageCount} message${summary.messageCount === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<article className="msg">
|
||||
<div className="msg-head">
|
||||
<Avatar
|
||||
name={displayName(summary.from)}
|
||||
address={summary.from.address}
|
||||
brand={isBrand(summary.from)}
|
||||
/>
|
||||
<div className="msg-who">
|
||||
<div className="msg-name">{displayName(summary.from)}</div>
|
||||
</div>
|
||||
<div className="msg-time">{messageTime(summary.dateMs)}</div>
|
||||
</div>
|
||||
<div className="msg-body">
|
||||
<BodySkeleton />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadingPane() {
|
||||
const place = useMail((s) => s.place);
|
||||
const thread = useMail((s) => s.thread);
|
||||
const opening = useMail((s) => s.opening);
|
||||
const phase = useMail((s) => s.threadPhase);
|
||||
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const [quoted, setQuoted] = useState<string[]>([]);
|
||||
const [focus, setFocus] = useState<number | null>(null);
|
||||
/** Messages re-rendered with their images fetched, which replaces the one the thread came with. */
|
||||
const [shown, setShown] = useState<Record<string, MessageView>>({});
|
||||
/**
|
||||
* Where the request for the pictures is. Rust fetches them and that is a network round trip per
|
||||
* host, so the button has to say it is working: pressed and unchanged for three seconds is the
|
||||
* one thing this pane must never look like.
|
||||
*/
|
||||
const [images, setImages] = useState<"idle" | "fetching">("idle");
|
||||
const imagesRequest = useRef(0);
|
||||
/** Same for the merge banner's verb, which is three round trips with nothing else on screen. */
|
||||
const [unmerging, setUnmerging] = useState<"idle" | "working">("idle");
|
||||
const [picker, setPicker] = useState<PickerMode | null>(null);
|
||||
const [noting, setNoting] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const scroller = useRef<HTMLDivElement | null>(null);
|
||||
const pane = useMail((s) => s.pane);
|
||||
const openKey = useMail((s) => s.openKey);
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const replyKey = useCompose((s) => s.replyKey);
|
||||
|
||||
const isMe = useIsMe();
|
||||
|
||||
const messages = useMemo(
|
||||
() => (thread?.messages ?? []).map((m) => shown[m.id] ?? m),
|
||||
[thread, shown],
|
||||
);
|
||||
|
||||
// A thread opens on its latest message with everything before it collapsed to a line, which is
|
||||
// the shape of every conversation you already know the beginning of.
|
||||
useEffect(() => {
|
||||
const last = thread?.messages.at(-1);
|
||||
setExpanded(last ? [last.id] : []);
|
||||
setQuoted([]);
|
||||
setFocus(null);
|
||||
setShown({});
|
||||
setImages("idle");
|
||||
imagesRequest.current += 1;
|
||||
setUnmerging("idle");
|
||||
// The failed slots were the last thread's, and nothing in `open` knows about them.
|
||||
useMail.getState().clearBodyPhase();
|
||||
scroller.current?.scrollTo({ top: 0 });
|
||||
}, [thread?.key]);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((was) => (was.includes(id) ? was.filter((x) => x !== id) : [...was, id]));
|
||||
|
||||
// A reply belongs to the thread it answers, so leaving the thread parks it with the provider and
|
||||
// takes the box off the screen rather than carrying it to the next conversation.
|
||||
useEffect(() => {
|
||||
const compose = useCompose.getState();
|
||||
if (compose.replyKey && compose.replyKey !== thread?.key) compose.closeReply();
|
||||
}, [thread?.key]);
|
||||
|
||||
// A box that opened below the fold is a box you have to go and find.
|
||||
useEffect(() => {
|
||||
if (!reply || replyKey !== thread?.key) return;
|
||||
scroller.current?.querySelector(".reply")?.scrollIntoView({ block: "nearest" });
|
||||
}, [reply !== null, replyKey, thread?.key]);
|
||||
|
||||
// `r`, `a` and `f`. All three open the same box under the last message with different people in
|
||||
// it, and `a` on a box that is already open is the switch rather than a second box.
|
||||
useEffect(() => {
|
||||
if (!thread) return;
|
||||
const answer = (kind: ComposerKind, all: boolean) => {
|
||||
const view = useMail.getState().thread;
|
||||
const last = view?.messages.at(-1);
|
||||
if (!view || !last) return;
|
||||
useCompose.getState().answer(view, last, kind, all);
|
||||
};
|
||||
return registerCommands({
|
||||
reply: () => answer("reply", useSettings.getState().settings?.replyAllDefault ?? false),
|
||||
"reply-all": () => {
|
||||
const compose = useCompose.getState();
|
||||
const open = compose.replyKey === thread.key ? compose.reply : null;
|
||||
if (open && open.kind !== "forward") compose.setAll(!open.all);
|
||||
else answer("reply", true);
|
||||
},
|
||||
forward: () => answer("forward", false),
|
||||
});
|
||||
}, [thread?.key, thread]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) return;
|
||||
const at = (delta: number) => {
|
||||
setFocus((was) => {
|
||||
const next = was === null ? messages.length - 1 : was + delta;
|
||||
return Math.min(Math.max(next, 0), messages.length - 1);
|
||||
});
|
||||
};
|
||||
return registerCommands({
|
||||
"message-next": () => at(1),
|
||||
"message-prev": () => at(-1),
|
||||
"message-toggle": () => {
|
||||
const id = messages[focus ?? messages.length - 1]?.id;
|
||||
if (id) toggle(id);
|
||||
},
|
||||
"message-expand-all": () => setExpanded(messages.map((m) => m.id)),
|
||||
});
|
||||
}, [thread, messages, focus]);
|
||||
|
||||
// With the reading pane hidden the thread is the page and the list is not on screen, so the
|
||||
// triage verbs act on what is open here. While the list is up they are the list's, which is what
|
||||
// keeps `e` on the row the keyboard is on rather than on whatever was opened last.
|
||||
const alone = !pane && openKey !== null;
|
||||
useEffect(() => {
|
||||
if (!alone || !openKey) return;
|
||||
const keys = [openKey];
|
||||
return registerCommands({
|
||||
archive: () => triage.archive(keys),
|
||||
"toggle-seen": () => triage.toggleSeen(keys),
|
||||
"toggle-star": () => triage.toggleStar(keys),
|
||||
trash: () => triage.trash(keys),
|
||||
spam: () => triage.spam(keys),
|
||||
label: () => setPicker("apply"),
|
||||
undo: () => void triage.undo(),
|
||||
"reply-later": () => void usePiles.getState().toggle(keys, "reply-later"),
|
||||
"set-aside": () => void usePiles.getState().toggle(keys, "set-aside"),
|
||||
snooze: () => useSnooze.getState().show(keys, snoozeAnchor()),
|
||||
note: () => setNoting(keys[0]),
|
||||
// The subject in the pane is the control. This is how somebody who has not discovered that
|
||||
// finds it, which is what the palette is for.
|
||||
rename: () => setRenaming(keys[0]),
|
||||
// `g` is not here. A merge is two threads or more and there is one thread on this screen,
|
||||
// so the key would have nothing to act on.
|
||||
});
|
||||
}, [alone, openKey]);
|
||||
|
||||
// The focused message has to be on screen, or `n` walks the thread from behind the fold.
|
||||
useEffect(() => {
|
||||
if (focus === null) return;
|
||||
const id = messages[focus]?.id;
|
||||
if (!id) return;
|
||||
const el = scroller.current?.querySelector(`[data-message="${CSS.escape(id)}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
// An invitation is answered with `y`, `m` and `n`, and those three are the card's only while
|
||||
// the card holds the focus. Walking onto the message that carries one is how a keyboard
|
||||
// reaches it; a card you had to click first would be a card the keyboard could not answer.
|
||||
el?.querySelector<HTMLElement>(".invite")?.focus();
|
||||
}, [focus, messages]);
|
||||
|
||||
if (!thread) {
|
||||
// The row is enough to be the new thread already. Blank is only for a thread opened from
|
||||
// somewhere with no row behind it, which gets the skeleton while its view is read, and for
|
||||
// nothing being open at all.
|
||||
if (opening) return <OpeningPane summary={opening} />;
|
||||
return (
|
||||
<section className="pane">
|
||||
<div className="pane-blank">
|
||||
{phase === "loading" ? <BodySkeleton /> : <EmptyState>Nothing selected</EmptyState>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const trackers = messages.flatMap((m) => m.trackers);
|
||||
const blocked = messages.filter((m) => m.blockedImages > 0);
|
||||
// Asked for and still not there: a host that would not answer. The banner says so rather than
|
||||
// offering the same button again as if nothing had happened.
|
||||
const missing = blocked.reduce((n, m) => (m.imagesLoaded ? n + m.blockedImages : n), 0);
|
||||
const vendors = [...new Set(trackers.map((t) => t.vendor))];
|
||||
|
||||
const showImages = async () => {
|
||||
if (images === "fetching") return;
|
||||
const mine = ++imagesRequest.current;
|
||||
setImages("fetching");
|
||||
const results = await Promise.allSettled(blocked.map((m) => messageShowImages(m.id)));
|
||||
const next: Record<string, MessageView> = {};
|
||||
let failure: unknown = null;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") next[blocked[index].id] = result.value;
|
||||
else failure ??= result.reason;
|
||||
});
|
||||
setShown((was) => ({ ...was, ...next }));
|
||||
// Another thread has been opened since, and its banner is not this request's to settle.
|
||||
if (imagesRequest.current !== mine) return;
|
||||
setImages("idle");
|
||||
// A press that came to nothing has to say so, or the button is the one that looked stuck.
|
||||
if (failure !== null) notify(`Could not load the images: ${String(failure)}`);
|
||||
};
|
||||
|
||||
const unmerge = async () => {
|
||||
if (unmerging === "working") return;
|
||||
setUnmerging("working");
|
||||
await unmergeThread(thread.key);
|
||||
// Landed, the banner has already gone with the re-read; refused, the verb comes back.
|
||||
setUnmerging("idle");
|
||||
};
|
||||
|
||||
const wroteBack = messages.some((m) => m.sentByMe);
|
||||
const others = thread.participants.filter((p) => !isMe(p));
|
||||
const faces = wroteBack ? thread.participants : others;
|
||||
// The line names three of forty and counts the rest, so the forty are in the title: the answer to
|
||||
// "who else is on this" is one hover away rather than gone.
|
||||
const everyone = others.map(displayName).join(", ");
|
||||
|
||||
return (
|
||||
<section className="pane">
|
||||
<PaneBar notify={thread.notify} />
|
||||
|
||||
<div className="thread" ref={scroller}>
|
||||
<div className="thread-inner">
|
||||
<div className="thread-head">
|
||||
{/* The subject is the control that renames it. There is no verb for this in the
|
||||
keymap and no button beside it: the thing you want to change is the thing you
|
||||
press, which is how a file is renamed everywhere else. */}
|
||||
{/* Clamped to two lines with the whole of it in the title, because a subject is a
|
||||
sentence somebody typed and some of them carry the date, the room and your own
|
||||
address in them. Three lines at display size is a page you scroll past. */}
|
||||
<h1 className="thread-subject" title={thread.subject}>
|
||||
<button
|
||||
type="button"
|
||||
className="thread-name"
|
||||
title="Rename this thread"
|
||||
onClick={() => setRenaming(thread.key)}
|
||||
>
|
||||
{thread.subject}
|
||||
</button>
|
||||
{thread.originalSubject ? (
|
||||
<span className="renamed">{`renamed · was "${thread.originalSubject}"`}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<div className="thread-meta">
|
||||
<AvatarStack
|
||||
people={faces.map((p) => ({
|
||||
name: displayName(p),
|
||||
address: p.address,
|
||||
brand: isBrand(p),
|
||||
}))}
|
||||
/>
|
||||
<span className="thread-people" title={everyone}>
|
||||
{participantLine(others.map(displayName), wroteBack)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{`${messages.length} message${messages.length === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Where something else put this thread, and the way back out. The banner is the mouse
|
||||
path to a rescue and the place the thirty day rule belongs: the list's foot says it
|
||||
once for the whole place, and this says it about the thread you are looking at. */}
|
||||
{thread.trashed || thread.spam ? (
|
||||
<div className="thread-banner">
|
||||
<Banner
|
||||
icon={thread.trashed ? icons.TRASH : icons.SPAM}
|
||||
action={{
|
||||
label: thread.trashed ? "Put back" : "Not spam",
|
||||
onClick: () =>
|
||||
thread.trashed ? triage.trash([thread.key]) : triage.spam([thread.key]),
|
||||
}}
|
||||
>
|
||||
{thread.trashed
|
||||
? "In the trash. Gmail empties it after 30 days, and putting it back lands it where it was."
|
||||
: "Gmail marked this as spam. It empties spam after 30 days, and taking the mark off routes it like any other mail."}
|
||||
</Banner>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{thread.mergedFrom.length > 0 ? (
|
||||
<div className="thread-banner">
|
||||
<Banner
|
||||
tone="muted"
|
||||
icon={icons.MERGE}
|
||||
action={{
|
||||
label: "Unmerge",
|
||||
busy: unmerging === "working",
|
||||
busyLabel: "Unmerging…",
|
||||
onClick: () => void unmerge(),
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{"Merged from "}
|
||||
<b>{`${thread.mergedFrom.length} threads`}</b>
|
||||
{` · ${thread.mergedFrom.map((m) => m.subject).join(", ")}`}
|
||||
</>
|
||||
</Banner>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trackers.length > 0 || blocked.length > 0 ? (
|
||||
<div className="thread-banner">
|
||||
<Banner
|
||||
icon={icons.SHIELD}
|
||||
action={
|
||||
blocked.length > 0
|
||||
? {
|
||||
label: missing > 0 ? "Try again" : "Show images",
|
||||
busy: images === "fetching",
|
||||
busyLabel: "Loading images…",
|
||||
onClick: () => void showImages(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* The count is only spoken when there is one. A message with no trackers and a
|
||||
remote image says the thing that is true about it, because a banner reading
|
||||
"Blocked 0 trackers" is the app taking credit for doing nothing. */}
|
||||
<>
|
||||
{trackers.length > 0 ? (
|
||||
<>
|
||||
{"Blocked "}
|
||||
<b>{`${trackers.length} tracker${trackers.length === 1 ? "" : "s"}`}</b>
|
||||
{vendors.length > 0 ? ` from ${vendors.join(", ")}. ` : ". "}
|
||||
</>
|
||||
) : null}
|
||||
{blocked.length > 0
|
||||
? missing > 0
|
||||
? `${missing} image${missing === 1 ? "" : "s"} did not load.`
|
||||
: "Remote images are off for this sender."
|
||||
: null}
|
||||
</>
|
||||
</Banner>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
accountId={thread.accountId}
|
||||
plain={place === "paper-trail"}
|
||||
open={expanded.includes(message.id)}
|
||||
focused={focus === index}
|
||||
quoted={quoted.includes(message.id)}
|
||||
me={isMe(message.from)}
|
||||
toYou={message.to.some(isMe)}
|
||||
onToggle={() => {
|
||||
setFocus(index);
|
||||
toggle(message.id);
|
||||
}}
|
||||
onQuoted={() =>
|
||||
setQuoted((was) =>
|
||||
was.includes(message.id)
|
||||
? was.filter((x) => x !== message.id)
|
||||
: [...was, message.id],
|
||||
)
|
||||
}
|
||||
notes={thread.notes.filter((n) => n.afterMessageId === message.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{thread.notes
|
||||
.filter((note) => note.afterMessageId === null)
|
||||
.map((note) => (
|
||||
<Note key={note.id} body={note.body} />
|
||||
))}
|
||||
|
||||
<SendingLine threadKey={thread.key} />
|
||||
|
||||
{/* The box a reply is written in, under what it answers. `r`, `a` and `f` open it and
|
||||
nothing else does: there is no permanent box at the foot of every thread, because
|
||||
most threads are read and not answered. */}
|
||||
{reply && replyKey === thread.key ? (
|
||||
<ReplyBox to={reply.sender[0] ?? thread.participants[0] ?? messages[0].from} composer={reply} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabelPicker mode={picker} keys={openKey ? [openKey] : []} onClose={() => setPicker(null)} />
|
||||
<NoteSheet threadKey={noting} onClose={() => setNoting(null)} />
|
||||
<RenameSheet
|
||||
threadKey={renaming}
|
||||
subject={thread.subject}
|
||||
original={thread.originalSubject}
|
||||
onClose={() => setRenaming(null)}
|
||||
/>
|
||||
{/* With the list on screen the picker is the list's, because `b` is the list's. Here it is
|
||||
mounted only when the thread is the page and there is no list to own it. */}
|
||||
{alone ? <SnoozePicker /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteSheetProps {
|
||||
/** The thread the note is about, or null when nothing is being written. */
|
||||
threadKey: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `y`. A private note about a thread, text only.
|
||||
*
|
||||
* A panel rather than a box in the pane, because `y` is pressed on a row in the list as often as
|
||||
* on an open thread, and a note you can only write with the thread in front of you is a note you
|
||||
* write after reading rather than while deciding.
|
||||
*/
|
||||
export function NoteSheet({ threadKey, onClose }: NoteSheetProps) {
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (threadKey) setBody("");
|
||||
}, [threadKey]);
|
||||
|
||||
if (!threadKey) return null;
|
||||
|
||||
const save = () => {
|
||||
onClose();
|
||||
void saveNote(threadKey, body);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
size="mini"
|
||||
title="Note to self"
|
||||
onClose={onClose}
|
||||
foot={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={body.trim().length === 0} onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label="Only you see this"
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Ask about the oak finish before confirming"
|
||||
multiline
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenameSheetProps {
|
||||
threadKey: string | null;
|
||||
subject: string;
|
||||
/** The real subject, when this thread already carries a name of its own. */
|
||||
original: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** A name of your own for a thread. Replies still carry the real subject, so threading holds. */
|
||||
export function RenameSheet({ threadKey, subject, original, onClose }: RenameSheetProps) {
|
||||
const [name, setName] = useState(subject);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadKey) setName(subject);
|
||||
}, [threadKey, subject]);
|
||||
|
||||
if (!threadKey) return null;
|
||||
|
||||
const rename = (to: string | null) => {
|
||||
onClose();
|
||||
void renameThread(threadKey, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
size="mini"
|
||||
title="Rename this thread"
|
||||
onClose={onClose}
|
||||
foot={
|
||||
<>
|
||||
{original ? <Button onClick={() => rename(null)}>Use the real subject</Button> : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={name.trim().length === 0 || name === subject}
|
||||
onClick={() => rename(name.trim())}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label="What you want to call it"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
hint={original ? `The real subject is "${original}", and replies still carry it.` : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Verb({ command, label, icon }: VerbSpec) {
|
||||
return (
|
||||
<Button variant="ghost" icon={icon} keycap={cap(command)} onClick={() => runCommand(command)}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages whose surface the reader has overruled, for as long as the app is open.
|
||||
*
|
||||
* Every heuristic misses, and the one behind `message.surface` misses in two directions: a page
|
||||
* painted only in a `<style>` rule reads as no page at all, and a signature block with a wash
|
||||
* behind it can read as one. So there is a way out, and it is one press.
|
||||
*
|
||||
* Deliberately not stored. A remembered override is a good idea and it is a different piece of
|
||||
* work: it is a decision about a sender rather than about a message, it belongs beside the other
|
||||
* per-sender decisions in the state database rather than in the mirror, and it has to roam through
|
||||
* the backup store with them. A `Map` for the session is the whole of what was asked for here.
|
||||
*/
|
||||
const overruled = new Map<string, Surface>();
|
||||
|
||||
/**
|
||||
* The one control that flips it.
|
||||
*
|
||||
* Only in dark, and only on a message that arrived as HTML. In the light palette the two surfaces
|
||||
* are the same warm white to within a shade nobody can name, so the button would be a control that
|
||||
* visibly does nothing; and a body this app set itself out of plain text was never on a sender's
|
||||
* page to be taken off one.
|
||||
*/
|
||||
function SurfaceToggle({ surface, onFlip }: { surface: Surface; onFlip: () => void }) {
|
||||
const paper = surface === "paper";
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
icon={icons.MOON}
|
||||
active={!paper}
|
||||
onClick={onFlip}
|
||||
title={paper ? "Read this message in dark" : "Read this message on a light page"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ body }: { body: string }) {
|
||||
return (
|
||||
<div className="note">
|
||||
<span className="note-label">Note to self</span>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageView;
|
||||
/** The mailbox this thread is in, which is the account whose scopes an invite is answered with. */
|
||||
accountId: string;
|
||||
plain: boolean;
|
||||
open: boolean;
|
||||
focused: boolean;
|
||||
quoted: boolean;
|
||||
me: boolean;
|
||||
toYou: boolean;
|
||||
onToggle: () => void;
|
||||
onQuoted: () => void;
|
||||
notes: { id: string; body: string }[];
|
||||
}
|
||||
|
||||
function Message({
|
||||
message,
|
||||
accountId,
|
||||
plain,
|
||||
open,
|
||||
focused,
|
||||
quoted,
|
||||
me,
|
||||
toYou,
|
||||
onToggle,
|
||||
onQuoted,
|
||||
notes,
|
||||
}: MessageProps) {
|
||||
const who = me ? "You" : displayName(message.from);
|
||||
const theme = useTheme((s) => s.theme);
|
||||
const pending = useMail((s) => s.bodyPhase[message.id]);
|
||||
/**
|
||||
* Where each chip's request is, by attachment id. Rust hands the file to the OS once it has the
|
||||
* bytes, and the bytes are a network round trip when nobody has opened this file before, so the
|
||||
* chip has to say it is working the way the images button does.
|
||||
*/
|
||||
const [files, setFiles] = useState<Record<string, "idle" | "opening" | "error">>({});
|
||||
const openFile = async (id: string) => {
|
||||
if (files[id] === "opening") return;
|
||||
setFiles((was) => ({ ...was, [id]: "opening" }));
|
||||
try {
|
||||
await attachmentOpen(id);
|
||||
setFiles((was) => ({ ...was, [id]: "idle" }));
|
||||
} catch (e) {
|
||||
setFiles((was) => ({ ...was, [id]: "error" }));
|
||||
// As it is: the refusals are sentences written for the person holding the laptop.
|
||||
notify(String(e));
|
||||
}
|
||||
};
|
||||
const [override, setOverride] = useState<Surface | null>(() => overruled.get(message.id) ?? null);
|
||||
const surface = override ?? message.surface;
|
||||
const flip = () => {
|
||||
const next: Surface = surface === "paper" ? "theme" : "paper";
|
||||
overruled.set(message.id, next);
|
||||
setOverride(next);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className="msg"
|
||||
data-message={message.id}
|
||||
data-collapsed={open ? undefined : ""}
|
||||
data-focus={focused ? "" : undefined}
|
||||
>
|
||||
<div className="msg-head" onClick={open ? undefined : onToggle}>
|
||||
{/* The face is the sender's, whatever the line beside it calls them: a thread of your own
|
||||
replies is a column of your initials, not a column of the word You. */}
|
||||
<Avatar
|
||||
name={displayName(message.from)}
|
||||
address={message.from.address}
|
||||
brand={isBrand(message.from)}
|
||||
/>
|
||||
<div className="msg-who">
|
||||
<div className="msg-name">
|
||||
{who}
|
||||
{open && !me ? <span className="addr">{message.from.address}</span> : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="msg-to">{toYou ? "to you" : `to ${message.to.map(displayName).join(", ")}`}</div>
|
||||
) : (
|
||||
<div className="msg-preview">{previewOf(message.html)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="msg-aside">
|
||||
<span className="msg-time">{messageTime(message.dateMs)}</span>
|
||||
{open && theme === "dark" && message.isHtml ? (
|
||||
<SurfaceToggle surface={surface} onFlip={flip} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="msg-body">
|
||||
{message.bodyPending ? (
|
||||
pending === "error" ? (
|
||||
<BodyMissing onRetry={() => void useMail.getState().hydrateThread()} />
|
||||
) : (
|
||||
<BodySkeleton />
|
||||
)
|
||||
) : (
|
||||
<MessageBody html={message.html} plain={plain} surface={surface} />
|
||||
)}
|
||||
|
||||
{message.quotedHtml ? (
|
||||
<div className="msg-quoted">
|
||||
<Pill tone="quiet" onClick={onQuoted}>
|
||||
{quoted ? "Hide quoted text" : "··· Show quoted text"}
|
||||
</Pill>
|
||||
{quoted ? <MessageBody html={message.quotedHtml} plain={plain} surface={surface} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message.invite ? (
|
||||
<InviteCard invite={message.invite} messageId={message.id} accountId={accountId} />
|
||||
) : null}
|
||||
|
||||
{message.attachments.length > 0 ? (
|
||||
<div className="attachments">
|
||||
{message.attachments.map((file) => (
|
||||
<button
|
||||
type="button"
|
||||
className="attachment"
|
||||
key={file.id}
|
||||
data-phase={files[file.id] ?? "idle"}
|
||||
disabled={files[file.id] === "opening"}
|
||||
onClick={() => void openFile(file.id)}
|
||||
>
|
||||
<span className="ext">{fileKind(file.filename, file.mimeType)}</span>
|
||||
{file.filename}
|
||||
<span className="size">{fileSize(file.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notes.map((note) => (
|
||||
<Note key={note.id} body={note.body} />
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReadingPane;
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Avatar, Button, Confirm, EmptyState, Pill, Sheet, Toggle } from "../ui";
|
||||
import { registerCommands, runCommand } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import type { Destination, ScreenerCard } from "../ipc";
|
||||
import { useMail } from "../store/useMail";
|
||||
import {
|
||||
DESTINATIONS,
|
||||
destinationName,
|
||||
domainOf,
|
||||
domainRuleAllowed,
|
||||
useScreener,
|
||||
} from "../store/useScreener";
|
||||
import { cap, displayName, isBrand } from "./format";
|
||||
import { BodyMissing, BodySkeleton, MessageBody } from "./MessageBody";
|
||||
import "./screener.css";
|
||||
|
||||
/**
|
||||
* The Screener: the whole stage, one card per sender waiting at the door.
|
||||
*
|
||||
* A card is the message rather than a row that opens one, which is why this is not a list column:
|
||||
* everything a decision needs is on the card, and the decision is three buttons wide.
|
||||
*
|
||||
* Nothing here congratulates anybody and nothing here is a count except the sentence at the top,
|
||||
* which is the number of people it is about.
|
||||
*/
|
||||
|
||||
const WORDS = [
|
||||
"Nobody",
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
"Four",
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
"Eight",
|
||||
"Nine",
|
||||
"Ten",
|
||||
];
|
||||
|
||||
/** Small numbers read as words in a sentence and as digits in a pill, which is where the pill is. */
|
||||
const spell = (n: number): string => WORDS[n] ?? String(n);
|
||||
|
||||
export function Screener() {
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const cards = useScreener((s) => s.cards);
|
||||
const phase = useScreener((s) => s.phase);
|
||||
const focused = useScreener((s) => s.focused);
|
||||
const expanded = useScreener((s) => s.expanded);
|
||||
const deciding = useScreener((s) => s.deciding);
|
||||
const load = useScreener((s) => s.load);
|
||||
const focus = useScreener((s) => s.focus);
|
||||
const step = useScreener((s) => s.step);
|
||||
const toggleExpanded = useScreener((s) => s.toggleExpanded);
|
||||
const decide = useScreener((s) => s.decide);
|
||||
const clearAll = useScreener((s) => s.clearAll);
|
||||
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void load(accountId);
|
||||
}, [accountId, load]);
|
||||
|
||||
// The top card takes the keyboard as soon as there is one. Every verb on this screen acts on the
|
||||
// focused card, and a Screener whose first `y` does nothing is a Screener you press `y` at twice.
|
||||
useEffect(() => {
|
||||
if (focused === null && cards.length > 0) focus(cards[0].threadKey);
|
||||
}, [cards, focused, focus]);
|
||||
|
||||
// The card owns `y`, `v` and `n` while the Screener is up, which is the one place in the app
|
||||
// where a letter means something other than what the list would have made of it.
|
||||
useKeyContext("screener");
|
||||
// A panel is in front, so the card's keys and the view's both stand back until it closes.
|
||||
useKeyContext("overlay", picking !== null || clearing);
|
||||
|
||||
// Every verb here acts on the focused card, and on an empty pile there is none, which is how a
|
||||
// key that would act on nothing comes to do nothing. A card whose decision is already out is
|
||||
// the same: its buttons are down, and the letter is the same button.
|
||||
useEffect(() => {
|
||||
const on = (run: (card: ScreenerCard) => void) => () => {
|
||||
const state = useScreener.getState();
|
||||
const card = state.cards.find((c) => c.threadKey === state.focused);
|
||||
if (card && !state.deciding.includes(card.threadKey)) run(card);
|
||||
};
|
||||
return registerCommands({
|
||||
"screen-yes": on((card) => void decide(card.threadKey, card.suggestion, false)),
|
||||
"screen-elsewhere": on((card) => setPicking(card.threadKey)),
|
||||
"screen-no": on((card) => void decide(card.threadKey, "screened-out", false)),
|
||||
// `r` screens in to the Inbox and opens a reply, and there is no composer to open one in
|
||||
// yet. Half of it is worse than none of it, so nobody owns the verb and the button does
|
||||
// nothing, the same way Reply in the reading pane does nothing.
|
||||
"select-next": () => step(1),
|
||||
"select-prev": () => step(-1),
|
||||
"open-selection": () => {
|
||||
const key = useScreener.getState().focused;
|
||||
if (key) toggleExpanded(key);
|
||||
},
|
||||
undo: () => void useScreener.getState().undo(accountId),
|
||||
});
|
||||
}, [accountId, decide, step, toggleExpanded]);
|
||||
|
||||
const picked = useMemo(
|
||||
() => cards.find((c) => c.threadKey === picking) ?? null,
|
||||
[cards, picking],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="stage">
|
||||
<section className="screener">
|
||||
<div className="screen-intro">
|
||||
<h1>Screener</h1>
|
||||
{cards.length > 0 ? (
|
||||
<p>
|
||||
{`${spell(cards.length)} ${cards.length === 1 ? "person" : "people"} wrote to you for the first time. Say where their mail goes, or that it goes nowhere. Nobody is told.`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<div className="screen-blank">
|
||||
{phase === "loading" ? null : <EmptyState>No one is waiting</EmptyState>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="screen-list">
|
||||
<div className="screen-tools">
|
||||
<Button variant="ghost" onClick={() => setClearing(true)}>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cards.map((card) => (
|
||||
<Card
|
||||
key={card.threadKey}
|
||||
card={card}
|
||||
focused={card.threadKey === focused}
|
||||
open={card.threadKey === expanded}
|
||||
deciding={deciding.includes(card.threadKey)}
|
||||
onFocus={() => focus(card.threadKey)}
|
||||
onDecide={(destination) => void decide(card.threadKey, destination, false)}
|
||||
onElsewhere={() => setPicking(card.threadKey)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<p className="screen-note">
|
||||
Screened-out mail sits under Screened out for as long as this account keeps mail on the
|
||||
device. Change your mind from a sender's contact card at any time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Picker
|
||||
card={picked}
|
||||
onClose={() => setPicking(null)}
|
||||
onChoose={(destination, wholeDomain) => {
|
||||
setPicking(null);
|
||||
if (picked) void decide(picked.threadKey, destination, wholeDomain);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Sheet
|
||||
open={clearing}
|
||||
size="mini"
|
||||
title="Clear the Screener"
|
||||
onClose={() => setClearing(false)}
|
||||
>
|
||||
<Confirm
|
||||
title={`Screen out ${spell(cards.length).toLowerCase()} ${cards.length === 1 ? "sender" : "senders"}?`}
|
||||
body={
|
||||
<p>
|
||||
Nothing is sent. Their mail goes to Screened out from now on, and any of them can be
|
||||
let back in from their contact card.
|
||||
</p>
|
||||
}
|
||||
confirmLabel="Screen them out"
|
||||
onConfirm={() => {
|
||||
setClearing(false);
|
||||
void clearAll(accountId);
|
||||
}}
|
||||
onCancel={() => setClearing(false)}
|
||||
/>
|
||||
</Sheet>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
card: ScreenerCard;
|
||||
focused: boolean;
|
||||
open: boolean;
|
||||
/** The decision is out. The card stays, with its buttons down, until it comes back. */
|
||||
deciding: boolean;
|
||||
onFocus: () => void;
|
||||
onDecide: (destination: Destination) => void;
|
||||
onElsewhere: () => void;
|
||||
}
|
||||
|
||||
function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere }: CardProps) {
|
||||
const view = useScreener((s) => s.views[card.threadKey]);
|
||||
const viewPhase = useScreener((s) => s.viewPhase[card.threadKey]);
|
||||
const retryView = useScreener((s) => s.retryView);
|
||||
const name = displayName(card.sender);
|
||||
const suggested = destinationName(card.suggestion);
|
||||
const message = view?.messages.at(-1);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="screen-card"
|
||||
data-sender={card.sender.address}
|
||||
data-selected={focused ? "" : undefined}
|
||||
data-state={deciding ? "deciding" : undefined}
|
||||
onClick={onFocus}
|
||||
>
|
||||
<Avatar name={name} address={card.sender.address} brand={isBrand(card.sender)} />
|
||||
|
||||
<div className="screen-who">
|
||||
<div className="screen-name">
|
||||
{name}
|
||||
<span className="addr">{card.sender.address}</span>
|
||||
</div>
|
||||
<div className="screen-subject">{card.subject}</div>
|
||||
<div className="screen-snippet">{card.snippet}</div>
|
||||
<Pill tone="wash">{`${card.reason} · suggested ${suggested}`}</Pill>
|
||||
</div>
|
||||
|
||||
<div className="screen-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
keycap={cap("screen-yes")}
|
||||
disabled={deciding}
|
||||
onClick={() => onDecide(card.suggestion)}
|
||||
>
|
||||
{`Yes, to ${suggested}`}
|
||||
</Button>
|
||||
<Button keycap={cap("screen-elsewhere")} disabled={deciding} onClick={onElsewhere}>
|
||||
Elsewhere
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
keycap={cap("screen-no")}
|
||||
disabled={deciding}
|
||||
onClick={() => onDecide("screened-out")}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="screen-message">
|
||||
{message ? (
|
||||
<MessageBody html={message.html} surface={message.surface} />
|
||||
) : viewPhase === "error" ? (
|
||||
<BodyMissing onRetry={() => retryView(card.threadKey)} />
|
||||
) : (
|
||||
<BodySkeleton />
|
||||
)}
|
||||
<div className="screen-message-foot">
|
||||
<Button keycap={cap("screen-reply")} onClick={() => runCommand("screen-reply")}>
|
||||
Reply
|
||||
</Button>
|
||||
<span className="screen-message-note">
|
||||
Replying screens this sender in to your Inbox.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
card: ScreenerCard | null;
|
||||
onClose: () => void;
|
||||
onChoose: (destination: Destination, wholeDomain: boolean) => void;
|
||||
}
|
||||
|
||||
/** `v`. The three boxes, and the domain rule where a domain can carry one. */
|
||||
function Picker({ card, onClose, onChoose }: PickerProps) {
|
||||
const [wholeDomain, setWholeDomain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (card) setWholeDomain(false);
|
||||
}, [card]);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
const domain = domainOf(card.sender.address);
|
||||
// A consumer domain is not one sender, and `state::write::set_rule` refuses the rule, so the
|
||||
// toggle is not offered rather than offered and taken back.
|
||||
const allowed = domainRuleAllowed(card.sender.address);
|
||||
|
||||
return (
|
||||
<Sheet open size="mini" title="Where does their mail go?" onClose={onClose}>
|
||||
<ul className="screen-picker">
|
||||
{DESTINATIONS.map((option) => (
|
||||
<li key={option.destination}>
|
||||
<button
|
||||
type="button"
|
||||
className="screen-option"
|
||||
onClick={() => onChoose(option.destination, allowed && wholeDomain)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{allowed ? (
|
||||
<Toggle
|
||||
checked={wholeDomain}
|
||||
onChange={setWholeDomain}
|
||||
label={`Everyone at ${domain}`}
|
||||
note="One rule for the whole company rather than for this person."
|
||||
/>
|
||||
) : null}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Screener;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Icon, icons, Key, NO_AUTOFILL, Pill } from "../ui";
|
||||
import { keyLabel } from "../keys/bindings";
|
||||
import { registerCommands } from "../keys/commands";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useMail } from "../store/useMail";
|
||||
import { useSearch } from "../store/useSearch";
|
||||
import "./search.css";
|
||||
|
||||
/** Long enough that a fast typist sends one query rather than eight, short enough to feel live. */
|
||||
const SETTLE_MS = 140;
|
||||
|
||||
/**
|
||||
* Search lives in the header, where it is on every screen and where `/` can always reach it.
|
||||
*
|
||||
* The field is a field and nothing more: the query goes to Rust, which parses the operators, and
|
||||
* the results land in the list column. Escape gives the place back that the results took.
|
||||
*/
|
||||
export function SearchBar() {
|
||||
const phase = useSearch((s) => s.phase);
|
||||
const query = useSearch((s) => s.query);
|
||||
const setQuery = useSearch((s) => s.setQuery);
|
||||
const run = useSearch((s) => s.run);
|
||||
const close = useSearch((s) => s.close);
|
||||
const openSearch = useSearch((s) => s.open);
|
||||
|
||||
const field = useRef<HTMLInputElement | null>(null);
|
||||
const on = phase !== "off";
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands({
|
||||
search: () => {
|
||||
useSearch.getState().open();
|
||||
// Already open is not nothing: `/` a second time is how you get back to the query.
|
||||
field.current?.select();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (on) field.current?.focus();
|
||||
}, [on]);
|
||||
|
||||
// One query per pause rather than one per keystroke. The phase is deliberately not a dependency:
|
||||
// it changes twice inside every run, and an effect that watched it would search forever.
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
const timer = window.setTimeout(() => void run(), SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [query, on, run]);
|
||||
|
||||
useEscapeLayer(on, close);
|
||||
|
||||
if (!on) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
icon={icons.SEARCH}
|
||||
title={`Search (${keyLabel("/")})`}
|
||||
onClick={openSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search" data-phase={phase} aria-busy={phase === "searching"}>
|
||||
<Icon d={icons.SEARCH} size={14} />
|
||||
<input
|
||||
ref={field}
|
||||
className="search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
aria-label="Search"
|
||||
placeholder="Search this mailbox"
|
||||
{...NO_AUTOFILL}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Down and Return hand the keyboard to the results, which is where the verbs are.
|
||||
if (e.key !== "ArrowDown" && e.key !== "Enter") return;
|
||||
const first = useMail.getState().threads[0];
|
||||
if (!first) return;
|
||||
e.preventDefault();
|
||||
useMail.getState().focus(first.key);
|
||||
field.current?.blur();
|
||||
}}
|
||||
/>
|
||||
<Key size="sm">⎋</Key>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The operators, drawn rather than understood.
|
||||
*
|
||||
* Rust parses the query and this recognises the shape of an operator so that `from:maya` reads as
|
||||
* something the app knows about rather than as a typo. It is not a parser and it must not become
|
||||
* one: if the two ever disagree, the answer in the list is the one that is right.
|
||||
*/
|
||||
const OPERATOR = /^[a-z]+:./i;
|
||||
|
||||
export function QueryTerms({ query }: { query: string }) {
|
||||
const terms = query.split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return null;
|
||||
return (
|
||||
<span className="search-terms">
|
||||
{terms.map((term, at) =>
|
||||
OPERATOR.test(term) ? (
|
||||
<Pill key={`${term}-${at}`} tone="wash">
|
||||
{term}
|
||||
</Pill>
|
||||
) : (
|
||||
<span className="search-word" key={`${term}-${at}`}>
|
||||
{term}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Key, NO_AUTOFILL, Popover } from "../ui";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import type { SnoozeKind, SnoozeTimes } from "../ipc";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { useSnooze } from "../store/useSnooze";
|
||||
import "./snooze.css";
|
||||
|
||||
/**
|
||||
* `b`. Six choices, their keys, and the moment each of them means.
|
||||
*
|
||||
* The moments are computed here and not in Rust, because "this weekend" is a fact about the person
|
||||
* looking at the picker rather than about the mailbox: their clock, their zone, and the four times
|
||||
* they set once in settings. Rust is handed an instant.
|
||||
*
|
||||
* The two choices that ask for a date open in place rather than in a second panel. A picker that
|
||||
* had to be dismissed to reach a date field would be two overlays deep for the most ordinary thing
|
||||
* on it.
|
||||
*/
|
||||
|
||||
const clock = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
const dayMonth = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const midnight = (ms: number): number => {
|
||||
const day = new Date(ms);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
return day.getTime();
|
||||
};
|
||||
|
||||
/** Whole calendar days from now to then, negative for a moment that has already gone. */
|
||||
const daysAhead = (ms: number, now: number): number =>
|
||||
Math.round((midnight(ms) - midnight(now)) / DAY_MS);
|
||||
|
||||
/**
|
||||
* When a snooze is due, said the way a person would.
|
||||
*
|
||||
* A moment that has passed says so plainly rather than pretending: a thread that should have come
|
||||
* back yesterday and is still waiting is "Due yesterday", not "Yesterday 08:00", because the point
|
||||
* of the line is that it is late.
|
||||
*/
|
||||
export function returnTime(ms: number, now = Date.now()): string {
|
||||
const days = daysAhead(ms, now);
|
||||
if (ms <= now) {
|
||||
if (days === 0) return "Due today";
|
||||
if (days === -1) return "Due yesterday";
|
||||
return `Due ${dayMonth.format(ms)}`;
|
||||
}
|
||||
if (days === 0) return `Today ${clock.format(ms)}`;
|
||||
if (days === 1) return `Tomorrow ${clock.format(ms)}`;
|
||||
if (days < 7) return `${weekday.format(ms)} ${clock.format(ms)}`;
|
||||
return `${dayMonth.format(ms)} ${clock.format(ms)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A moment so many days on, at a time of day given in minutes from midnight.
|
||||
*
|
||||
* Minutes rather than hours because that is what `SnoozeTimes` says it is and what Rust stores:
|
||||
* eight in the morning is 480. Reading it as an hour put every Tomorrow at eight minutes past
|
||||
* midnight, which is the sort of wrong that looks right in a list of times until somebody misses
|
||||
* something.
|
||||
*/
|
||||
const atMinutes = (from: number, daysOn: number, minutes: number): number => {
|
||||
const day = new Date(from);
|
||||
day.setDate(day.getDate() + daysOn);
|
||||
day.setHours(0, Math.min(Math.max(minutes, 0), 24 * 60 - 1), 0, 0);
|
||||
return day.getTime();
|
||||
};
|
||||
|
||||
/** The next given weekday at the given time of day, and never a moment that has already gone. */
|
||||
function nextWeekdayAt(weekdayIndex: number, minutes: number, now: number): number {
|
||||
const ahead = (weekdayIndex - new Date(now).getDay() + 7) % 7;
|
||||
const candidate = atMinutes(now, ahead, minutes);
|
||||
return candidate > now ? candidate : atMinutes(now, ahead + 7, minutes);
|
||||
}
|
||||
|
||||
export interface SnoozeChoice {
|
||||
kind: SnoozeKind;
|
||||
label: string;
|
||||
keycap: string;
|
||||
/** The moment, or null when the choice is a question rather than an answer. */
|
||||
at: number | null;
|
||||
}
|
||||
|
||||
/** The six, in the order the picker lists them and the order their keys are in. */
|
||||
export function snoozeChoices(times: SnoozeTimes, now = Date.now()): SnoozeChoice[] {
|
||||
return [
|
||||
{
|
||||
kind: "later-today",
|
||||
label: "Later today",
|
||||
keycap: "1",
|
||||
at: now + times.laterTodayHours * 3_600_000,
|
||||
},
|
||||
{ kind: "tomorrow", label: "Tomorrow", keycap: "2", at: atMinutes(now, 1, times.tomorrowAt) },
|
||||
{ kind: "weekend", label: "This weekend", keycap: "3", at: nextWeekdayAt(6, times.weekendAt, now) },
|
||||
{ kind: "next-week", label: "Next week", keycap: "4", at: nextWeekdayAt(1, times.nextWeekAt, now) },
|
||||
{ kind: "date", label: "Pick a date and time", keycap: "5", at: null },
|
||||
{ kind: "if-no-reply", label: "If no reply by", keycap: "6", at: null },
|
||||
];
|
||||
}
|
||||
|
||||
/** What `<input type="datetime-local">` and `<input type="date">` want, in local time. */
|
||||
const asInput = (ms: number, withTime: boolean): string => {
|
||||
const d = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return withTime ? `${day}T${pad(d.getHours())}:${pad(d.getMinutes())}` : day;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the popover hangs from, which is the row the verb is about when there is one.
|
||||
*
|
||||
* `b` is a key rather than a control, so there is nothing it was pressed on. The focused row is
|
||||
* the honest anchor: it is the thing that is about to leave the list. Failing that the pane's bar,
|
||||
* which is where the Snooze button is, and failing that the head of the list.
|
||||
*/
|
||||
export function snoozeAnchor(): HTMLElement | null {
|
||||
// One at a time and in this order. A selector list would answer with whichever of them comes
|
||||
// first in the document, which is the head of the list every time.
|
||||
for (const selector of [".row[data-selected]", ".thread-head", ".pane-bar", ".list-head"]) {
|
||||
const found = document.querySelector<HTMLElement>(selector);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SnoozePicker() {
|
||||
const open = useSnooze((s) => s.open);
|
||||
const anchor = useSnooze((s) => s.anchor);
|
||||
const hide = useSnooze((s) => s.hide);
|
||||
const choose = useSnooze((s) => s.choose);
|
||||
const times = useSettings((s) => s.settings?.snoozeTimes);
|
||||
const settingsPhase = useSettings((s) => s.phase);
|
||||
const settingsError = useSettings((s) => s.error);
|
||||
|
||||
/** The choice that asked for a date, and what has been typed into it. */
|
||||
const [asking, setAsking] = useState<SnoozeChoice | null>(null);
|
||||
const [typed, setTyped] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAsking(null);
|
||||
setTyped("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// In front of the list, so the place keys stand back and the digits below mean these six.
|
||||
useKeyContext("overlay", open);
|
||||
|
||||
const choices = open && times ? snoozeChoices(times) : [];
|
||||
|
||||
const take = (choice: SnoozeChoice) => {
|
||||
if (!times) return;
|
||||
if (choice.at !== null) {
|
||||
void choose(choice.kind, choice.at);
|
||||
return;
|
||||
}
|
||||
// A date choice opens its field on tomorrow morning, so the commonest answer to both of them
|
||||
// is one more key rather than a form to fill in.
|
||||
setAsking(choice);
|
||||
setTyped(asInput(atMinutes(Date.now(), 1, times.tomorrowAt), choice.kind === "date"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || choices.length === 0) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return;
|
||||
const choice = choices.find((c) => c.keycap === e.key);
|
||||
if (!choice) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
take(choice);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const confirm = () => {
|
||||
if (!asking || !typed || !times) return;
|
||||
const at = new Date(typed).getTime();
|
||||
if (!Number.isFinite(at)) return;
|
||||
// A date with no time on it means the start of that day, which for a reminder is the morning
|
||||
// rather than midnight.
|
||||
void choose(asking.kind, asking.kind === "date" ? at : atMinutes(at, 0, times.tomorrowAt));
|
||||
};
|
||||
|
||||
// The popover comes up on the keystroke whether or not the four times are here yet. Before they
|
||||
// are it is a quiet line, or the reason they are not coming; a `b` that showed nothing at all
|
||||
// until settings had been read was a `b` that got pressed twice.
|
||||
const waiting = !times;
|
||||
const failed = waiting && settingsPhase === "error";
|
||||
|
||||
return (
|
||||
<Popover open anchor={anchor} onClose={hide} width={264} label="Snooze until">
|
||||
<div
|
||||
className="snooze-picker"
|
||||
data-state={failed ? "error" : waiting ? "loading" : undefined}
|
||||
>
|
||||
{waiting ? (
|
||||
<p className="snooze-wait">
|
||||
{failed ? `Could not read your snooze times: ${settingsError}` : "Reading your snooze times"}
|
||||
</p>
|
||||
) : asking ? (
|
||||
<div className="snooze-form">
|
||||
<label className="snooze-field">
|
||||
{asking.label}
|
||||
<input
|
||||
type={asking.kind === "date" ? "datetime-local" : "date"}
|
||||
value={typed}
|
||||
autoFocus
|
||||
{...NO_AUTOFILL}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") confirm();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="snooze-form-foot">
|
||||
<Button variant="ghost" size="sm" onClick={() => setAsking(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={confirm}>
|
||||
Snooze
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="snooze-list">
|
||||
{choices.map((choice) => (
|
||||
<li key={choice.kind}>
|
||||
<button type="button" className="snooze-option" onClick={() => take(choice)}>
|
||||
<Key size="sm">{choice.keycap}</Key>
|
||||
<span className="snooze-label">{choice.label}</span>
|
||||
<span className="snooze-when">
|
||||
{choice.at === null ? "" : returnTime(choice.at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SnoozePicker;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react";
|
||||
import { Toast } from "../ui";
|
||||
import { useToast } from "../store/useToast";
|
||||
|
||||
/** Long enough to read it and reach for Undo, short enough not to sit over the list. */
|
||||
const DISMISS_MS = 6000;
|
||||
|
||||
/**
|
||||
* The acknowledgement at the foot of the window, and the only feedback a triage key gives: rows do
|
||||
* not animate when they leave for a pile or an archive, because a list that rearranges itself under
|
||||
* the hand is a list you stop trusting.
|
||||
*/
|
||||
export function Toasts() {
|
||||
const message = useToast((s) => s.message);
|
||||
const action = useToast((s) => s.action);
|
||||
const seq = useToast((s) => s.seq);
|
||||
const dismiss = useToast((s) => s.dismiss);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
const timer = window.setTimeout(dismiss, DISMISS_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, seq, dismiss]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<Toast
|
||||
action={
|
||||
action
|
||||
? {
|
||||
label: action.label,
|
||||
keycap: action.keycap,
|
||||
onClick: () => {
|
||||
action.run();
|
||||
dismiss();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toasts;
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Key, Sheet } from "../ui";
|
||||
import type { CommandId } from "../keys/bindings";
|
||||
import { useOverlays } from "../store/useOverlays";
|
||||
import { cap } from "./format";
|
||||
import "./tour.css";
|
||||
|
||||
/**
|
||||
* The slideshow a newly added account ends on, and the first row of the help menu after that.
|
||||
*
|
||||
* Nine slides, because the first minute is the only one anybody spends looking for what is
|
||||
* different, and almost nothing here works the way their last mail client did. Each is a heading, a
|
||||
* small figure of the real screen, and a line or two: the figures are drawn from tokens rather than
|
||||
* being pictures, so they follow the theme and cannot go stale the way a screenshot does.
|
||||
*
|
||||
* Nothing in the copy claims anything docs/features.md does not specify, and every sentence still
|
||||
* reads with the keycaps taken out of it, which is what a phone does to them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A keycap, always the binding table's answer for that verb rather than a letter typed into the
|
||||
* copy, so a remapped key teaches the key it was remapped to. `cap` is what every other button in
|
||||
* the app prints: the bare letter when it is unmodified, real glyphs when it is not.
|
||||
*/
|
||||
function Cap({ of, size }: { of: CommandId; size?: "sm" | "md" }) {
|
||||
const key = cap(of);
|
||||
return key ? <Key size={size}>{key}</Key> : null;
|
||||
}
|
||||
|
||||
/** The eight verbs slide five is about, each printing what the table gives it. */
|
||||
const VERBS: readonly { command: CommandId; label: string }[] = [
|
||||
{ command: "archive", label: "Archive" },
|
||||
{ command: "toggle-seen", label: "Seen" },
|
||||
{ command: "trash", label: "Trash" },
|
||||
{ command: "spam", label: "Spam" },
|
||||
{ command: "note", label: "Note" },
|
||||
{ command: "ignore", label: "Ignore" },
|
||||
{ command: "merge", label: "Merge" },
|
||||
{ command: "contact-card", label: "Contact card" },
|
||||
];
|
||||
|
||||
/** The palette's groups, in the order it lists them. */
|
||||
const GROUPS: readonly string[] = ["Places", "Labels", "Other", "Actions", "People", "Settings"];
|
||||
|
||||
/** The snooze picker's choices, in the order it offers them. */
|
||||
const WHEN: readonly string[] = [
|
||||
"Later today",
|
||||
"Tomorrow",
|
||||
"This weekend",
|
||||
"Next week",
|
||||
"Pick a date and time",
|
||||
];
|
||||
|
||||
interface Slide {
|
||||
title: string;
|
||||
figure: ReactNode;
|
||||
body: ReactNode;
|
||||
}
|
||||
|
||||
const SLIDES: readonly Slide[] = [
|
||||
{
|
||||
title: "Nobody new reaches you until you say so",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-card">
|
||||
<span className="tour-avatar" />
|
||||
<span className="tour-stack">
|
||||
<span className="tour-bar" data-w="name" />
|
||||
<span className="tour-bar" data-w="subject" />
|
||||
<span className="tour-tag">Written by a person · suggested Inbox</span>
|
||||
</span>
|
||||
<span className="tour-choices">
|
||||
<span className="tour-chip">
|
||||
Yes
|
||||
<Cap of="screen-yes" size="sm" />
|
||||
</span>
|
||||
<span className="tour-chip">
|
||||
Elsewhere
|
||||
<Cap of="screen-elsewhere" size="sm" />
|
||||
</span>
|
||||
<span className="tour-chip">
|
||||
No
|
||||
<Cap of="screen-no" size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
The first message from a sender you have no rule for waits in the Screener rather than in
|
||||
a box. Yes <Cap of="screen-yes" /> sends them where the app suggests, Elsewhere{" "}
|
||||
<Cap of="screen-elsewhere" /> picks somewhere else, No <Cap of="screen-no" /> screens them
|
||||
out.
|
||||
</p>
|
||||
<p>
|
||||
Nothing is ever sent back to the sender either way. Everyone you already write to was
|
||||
screened in when the account was added, so what waits here is somebody genuinely new.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Three boxes, not one inbox",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-boxes">
|
||||
<span className="tour-box" data-on="">
|
||||
Inbox
|
||||
<Cap of="place-inbox" size="sm" />
|
||||
</span>
|
||||
<span className="tour-box">
|
||||
Feed
|
||||
<Cap of="place-feed" size="sm" />
|
||||
</span>
|
||||
<span className="tour-box">
|
||||
Paper Trail
|
||||
<Cap of="place-paper-trail" size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Inbox <Cap of="place-inbox" /> is people. Feed <Cap of="place-feed" /> is what you
|
||||
subscribed to, drawn open, with no counts and no read state. Paper Trail{" "}
|
||||
<Cap of="place-paper-trail" /> is receipts, confirmations and the mail a machine sent you.
|
||||
</p>
|
||||
<p>
|
||||
Where a sender goes is one decision rather than a filter to keep up, and the contact card
|
||||
changes it.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Reply later and Set aside, instead of flags",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-piles">
|
||||
<span className="tour-pile">
|
||||
<span className="tour-pile-edge" />
|
||||
<span className="tour-pile-card">
|
||||
<span className="tour-pile-label">Reply later</span>
|
||||
<span className="tour-bar" data-w="subject" />
|
||||
<span className="tour-bar" data-w="name" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="tour-pile">
|
||||
<span className="tour-pile-edge" />
|
||||
<span className="tour-pile-card">
|
||||
<span className="tour-pile-label">Set aside</span>
|
||||
<span className="tour-bar" data-w="subject" />
|
||||
<span className="tour-bar" data-w="name" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Reply later <Cap of="reply-later" /> and Set aside <Cap of="set-aside" /> move a thread out
|
||||
of the list and into a stack at its foot. The same key puts it back.
|
||||
</p>
|
||||
<p>
|
||||
Focus & Reply is every thread you owe an answer to, one under the next, with a reply box
|
||||
beside each one.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Snooze, and reminders when nobody answers",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-menu">
|
||||
{WHEN.map((when) => (
|
||||
<span className="tour-menu-row" key={when}>
|
||||
{when}
|
||||
</span>
|
||||
))}
|
||||
<span className="tour-menu-row" data-apart="">
|
||||
If no reply by
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Snooze <Cap of="snooze" /> takes a thread away until later today, tomorrow, this weekend,
|
||||
next week, or a date you pick. It comes back to the top of the place it left.
|
||||
</p>
|
||||
<p>
|
||||
If no reply by is the other half of the picker: the thread comes back only if nobody has
|
||||
written since, and a reply cancels it.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "One key per verb",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-verbs">
|
||||
{VERBS.map((verb) => (
|
||||
<span className="tour-verb" key={verb.command}>
|
||||
{verb.label}
|
||||
<Cap of={verb.command} size="sm" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Archive, seen, trash, spam, note, ignore, merge, contact card. One unmodified key each:
|
||||
nothing is chorded and nothing is modal.
|
||||
</p>
|
||||
<p>
|
||||
Every button prints the key it answers to, which is how the mouse teaches the keyboard,
|
||||
and the shortcut sheet <Cap of="shortcuts" /> is the whole table.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "The palette is the only menu",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-palette">
|
||||
<span className="tour-field">
|
||||
<span className="tour-bar" data-w="query" />
|
||||
</span>
|
||||
{GROUPS.map((group) => (
|
||||
<span className="tour-group" key={group}>
|
||||
<span className="tour-group-label">{group}</span>
|
||||
<span className="tour-bar" data-w="row" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
One panel <Cap of="command-palette" /> with everything in it: Places, Labels, Other,
|
||||
Actions, People and Settings. Typing filters across all six at once.
|
||||
</p>
|
||||
<p>
|
||||
There is no sidebar and no toolbar to hunt through. This is the only menu, and every
|
||||
setting in the app is reached from it.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "The things kept beside the mail",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-kept">
|
||||
<span className="tour-kept-item" data-note="">
|
||||
Note
|
||||
</span>
|
||||
<span className="tour-kept-item">Renamed</span>
|
||||
<span className="tour-kept-item">Merged</span>
|
||||
<span className="tour-kept-item">Clip</span>
|
||||
<span className="tour-kept-item">All files</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
A private note on a thread, a subject you renamed, threads merged into one, a passage saved
|
||||
as a clip, and every attachment in one place.
|
||||
</p>
|
||||
<p>
|
||||
None of it is a change to the mailbox. A rename is yours alone, and a reply still carries
|
||||
the subject the sender wrote.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Quiet by default",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-message">
|
||||
<span className="tour-banner">Images blocked</span>
|
||||
<span className="tour-blocked" />
|
||||
<span className="tour-bar" data-w="row" />
|
||||
<span className="tour-bar" data-w="subject" />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Notifications are off everywhere until you turn one on, for a thread, for a person, or for
|
||||
a place.
|
||||
</p>
|
||||
<p>
|
||||
Remote images are blocked and trackers are stripped before a message is drawn. The dock
|
||||
badge counts unseen Inbox threads and nothing else.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Undo covers everything",
|
||||
figure: (
|
||||
<div className="tour-fig">
|
||||
<span className="tour-toast">
|
||||
<span className="tour-toast-text">Archived</span>
|
||||
<span className="tour-toast-action">
|
||||
Undo
|
||||
<Cap of="undo" size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Undo <Cap of="undo" /> takes back the last thing you did: an archive, a pile, a screening
|
||||
decision, a whole selection at once, and a send inside its ten seconds.
|
||||
</p>
|
||||
<p>
|
||||
That is everything. The question mark in the corner opens this again, and the guide beside
|
||||
it goes into the rest.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function Tour() {
|
||||
const open = useOverlays((s) => s.open) === "tour";
|
||||
const close = useOverlays((s) => s.close);
|
||||
if (!open) return null;
|
||||
return <Slides onClose={close} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slides, as their own component so that which one is up is state that only exists while the
|
||||
* tour does. Reopening it from the help menu or the palette mounts this again and starts at one,
|
||||
* which is what somebody who asked for the tour a second time is asking for.
|
||||
*/
|
||||
function Slides({ onClose }: { onClose: () => void }) {
|
||||
const [at, setAt] = useState(0);
|
||||
const primary = useRef<HTMLButtonElement | null>(null);
|
||||
const last = at === SLIDES.length - 1;
|
||||
const slide = SLIDES[at];
|
||||
|
||||
// The primary control takes the focus, so Return and Space advance without this panel binding
|
||||
// either of them. The Sheet focuses `[data-autofocus]`, which Button has no way to carry through
|
||||
// to its element, so the focus is taken here instead.
|
||||
useEffect(() => {
|
||||
primary.current?.focus();
|
||||
}, []);
|
||||
|
||||
// The arrows are the panel's while it is open. The app's keymap is already shadowed by the
|
||||
// overlay frame, and neither arrow is bound in it anyway.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.isComposing || (e.key !== "ArrowRight" && e.key !== "ArrowLeft")) return;
|
||||
e.preventDefault();
|
||||
const delta = e.key === "ArrowRight" ? 1 : -1;
|
||||
setAt((was) => Math.min(SLIDES.length - 1, Math.max(0, was + delta)));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
title="Getting started"
|
||||
size="wide"
|
||||
onClose={onClose}
|
||||
foot={
|
||||
// The foot is flex-end, so this is one child that takes the row and lays itself out.
|
||||
<div className="tour-foot">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Skip
|
||||
</Button>
|
||||
<span className="tour-dots">
|
||||
{SLIDES.map((each, index) => (
|
||||
<button
|
||||
key={each.title}
|
||||
type="button"
|
||||
className="tour-dot"
|
||||
data-on={index === at ? "" : undefined}
|
||||
aria-current={index === at || undefined}
|
||||
aria-label={`Slide ${index + 1}: ${each.title}`}
|
||||
onClick={() => setAt(index)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<Button disabled={at === 0} onClick={() => setAt(at - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
ref={primary}
|
||||
variant="primary"
|
||||
onClick={() => (last ? onClose() : setAt(at + 1))}
|
||||
>
|
||||
{last ? "Done" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="tour" data-slide={at}>
|
||||
{/* Keyed on the slide, so each one arrives with the fade rather than the words changing
|
||||
under the eye. */}
|
||||
<div className="tour-slide" key={slide.title}>
|
||||
<h3 className="tour-title">{slide.title}</h3>
|
||||
{slide.figure}
|
||||
<div className="tour-copy">{slide.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tour;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui";
|
||||
import "./windowchoice.css";
|
||||
|
||||
/** The same five spans the Storage window row in Settings offers, in the same order. */
|
||||
export const WINDOWS = [
|
||||
{ days: 30, label: "30 days" },
|
||||
{ days: 90, label: "90 days" },
|
||||
{ days: 180, label: "180 days" },
|
||||
{ days: 365, label: "A year" },
|
||||
{ days: 0, label: "Everything" },
|
||||
];
|
||||
|
||||
/** A window as a sentence names it: "the last month", "the last year", "everything". */
|
||||
export function spanOf(days: number): string {
|
||||
if (days === 0) return "everything";
|
||||
if (days === 30) return "the last month";
|
||||
if (days === 365) return "the last year";
|
||||
return `the last ${days} days`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one question between an account being written and its mail arriving: how far back this
|
||||
* device holds. Asked here rather than assumed, because a month is a guess and a year of a busy
|
||||
* mailbox is a long wait nobody was told about. The welcome screen and the arriving panel both
|
||||
* host it, each with its own heading; this is the row of spans and the button that starts the
|
||||
* sync, and nothing else.
|
||||
*/
|
||||
export function WindowChoice({
|
||||
initial,
|
||||
busy,
|
||||
onStart,
|
||||
}: {
|
||||
initial: number;
|
||||
busy: boolean;
|
||||
onStart: (days: number) => void;
|
||||
}) {
|
||||
const [days, setDays] = useState(initial);
|
||||
return (
|
||||
<div className="window-choice">
|
||||
<div className="window-choice-options" role="radiogroup" aria-label="How far back">
|
||||
{WINDOWS.map((option) => (
|
||||
<button
|
||||
key={option.days}
|
||||
type="button"
|
||||
role="radio"
|
||||
className="window-choice-option"
|
||||
aria-checked={option.days === days}
|
||||
data-on={option.days === days ? "" : undefined}
|
||||
disabled={busy}
|
||||
onClick={() => setDays(option.days)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="window-choice-note">
|
||||
Older mail stays on the server and is fetched when you search for it. This can be changed
|
||||
later in Settings.
|
||||
</p>
|
||||
<div className="window-choice-actions">
|
||||
<Button variant="primary" disabled={busy} onClick={() => onStart(days)}>
|
||||
{busy ? "Starting" : "Start"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* The selection's bar, which fills the footprint the two piles leave: same box, same border, same
|
||||
surface, so the foot of the list does not jump when a selection appears. */
|
||||
|
||||
.action-bar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* The piles' box to the pixel: their card, the padding around it, and the rule above it. The
|
||||
foot of the list must not move when a selection appears. */
|
||||
height: calc(var(--pile-h) + 10px + 12px + 1px);
|
||||
padding: 10px 14px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--shell);
|
||||
}
|
||||
|
||||
.action-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-count {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.action-clear:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Three across and two down inside the piles' height. The verbs keep one order everywhere in the
|
||||
app, so the hand that learned the bar has learned the keys. */
|
||||
.action-verbs {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-verbs .button {
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-verbs .button .key {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The label picker
|
||||
--------------------------------------------------------------------------------------------- */
|
||||
|
||||
.label-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.label-option:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.label-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label-option .icon {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.label-none {
|
||||
padding: 7px 8px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
/* The same line when the read failed, with the way to ask again beside it. */
|
||||
.label-none[data-state="error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
:root[data-touch] .label-option,
|
||||
:root[data-touch] .action-clear {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* The panel over the window while an added account brings its mail in. The welcome screen's bar
|
||||
and count, at the panel's width and twice the height, because this is the only thing on the
|
||||
screen and it should read from across the room. */
|
||||
|
||||
.arrive {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.arrive-line {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--t-4);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.arrive-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-wash);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.arrive-fill {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
transition: transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
/* Before there is a total there is nothing to fill against, so a run of the bar walks the track. */
|
||||
.arrive-bar[data-counting] .arrive-fill {
|
||||
width: 40%;
|
||||
animation: arrive-walk 1.4s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes arrive-walk {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
.arrive-count {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.arrive-quiet {
|
||||
margin: 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The provider's own sentence, verbatim, in the voice a refusal is printed in elsewhere. */
|
||||
.arrive-trouble {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arrive-bar[data-counting] .arrive-fill {
|
||||
width: 100%;
|
||||
animation: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/* The compose card and the reply box in the thread.
|
||||
*
|
||||
* Two shapes of one thing. The card floats over the stage at the bottom right and can take the
|
||||
* whole window; the box sits in the thread at the thread's own measure, under what it answers. The
|
||||
* fields, the chips, the foot and the editor are shared, so they are written once here and the two
|
||||
* containers differ only in where they are and what holds them up.
|
||||
*
|
||||
* A draft's attachments carry `.attachment` and `.attachments`, which pane.css already styles for
|
||||
* the ones on a received message. That is deliberate reuse and not a collision: a file on the way
|
||||
* out and a file that arrived are the same chip, and giving them two rules is how they stop being. */
|
||||
|
||||
.compose {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 30;
|
||||
width: var(--compose-w);
|
||||
max-height: var(--compose-max-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-page);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The whole window, which is Cmd+Shift+P. Inset rather than edge to edge: a card that touched the
|
||||
frame would stop reading as something in front of the mailbox. */
|
||||
.compose[data-expanded] {
|
||||
top: calc(var(--titlebar-h) + 16px);
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.compose-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--shell);
|
||||
}
|
||||
|
||||
.compose-head h2 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
/* One row of the head: a label, what is in it, and whatever hangs off the right. */
|
||||
.compose-field {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 4px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.compose-field .lab {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
padding-top: 6px;
|
||||
width: 52px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.compose-field .chips {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.compose-field .more {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
padding: 6px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.compose-field .more:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 6px 0 3px;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-off {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.chip-off:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The field the caret is in, which grows with what is typed rather than scrolling a fixed box. */
|
||||
.chip-input {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.chip-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.compose-subject {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.compose-subject:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Autocomplete, hanging under the field it is about. It is not a Popover: a popover is anchored in
|
||||
fixed coordinates and closes on a pointer down anywhere else, and this has to stay open while the
|
||||
caret keeps typing into the field above it. */
|
||||
.suggest {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 62px;
|
||||
right: 16px;
|
||||
top: 100%;
|
||||
margin: 2px 0 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.suggest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
text-align: left;
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.suggest-row[data-on],
|
||||
.suggest-row:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.suggest-name {
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggest-addr {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.from-list {
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.from-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
text-align: left;
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.from-option:hover,
|
||||
.from-option[data-on] {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
/* The body. It scrolls rather than the card, so the head and the foot stay where the hand left
|
||||
them however long the message gets. */
|
||||
.compose-body {
|
||||
flex: 1;
|
||||
min-height: var(--compose-body-h);
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.compose-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.compose-foot .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compose-delay {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Said before the send rather than after it, which is the whole point of the provider's limit
|
||||
being checked on every save. */
|
||||
.compose-warn {
|
||||
color: var(--danger-ink);
|
||||
font-size: var(--t-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compose-date {
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* The reply box, at the foot of the thread. */
|
||||
|
||||
.reply {
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--raised);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reply-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.reply-who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-head b {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Reply all is a switch rather than a second box, and it prints the key that does the same thing
|
||||
from the thread. */
|
||||
.reply-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px 3px 4px;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-all:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reply-all[data-on] {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reply-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.reply-close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reply .compose-field {
|
||||
padding-left: 14px;
|
||||
padding-right: 12px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.reply .suggest {
|
||||
left: 60px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.reply-body {
|
||||
min-height: var(--reply-body-h);
|
||||
padding: 12px 14px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.reply .compose-foot {
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
/* Waiting to send: the one line a thread carries while its own message is still in the outbox. */
|
||||
.sending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.sending .icon {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.sending-now {
|
||||
margin-left: auto;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.sending-now:disabled {
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* On a phone the card is the window, because there is no room for it to be anything else. */
|
||||
:root[data-phone] .compose {
|
||||
top: calc(var(--safe-top) + var(--phonebar-h));
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
border-width: 1px 0 0;
|
||||
}
|
||||
|
||||
:root[data-phone] .chip-input,
|
||||
:root[data-phone] .compose-subject {
|
||||
font-size: var(--t-5);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* The welcome screen is the only place in the app that is not a list, a pane or an overlay, so
|
||||
these are its own pieces. It is one column of centred text and whichever of the four states is
|
||||
in force, which is why the states share the mark, the heading and the quiet paragraph. */
|
||||
|
||||
.welcome {
|
||||
margin: auto;
|
||||
padding: 0 24px 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-mark {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.welcome-plate {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.welcome-glyph {
|
||||
fill: none;
|
||||
stroke: var(--accent-contrast);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.welcome-line {
|
||||
margin: 9px 0 28px;
|
||||
max-width: 30em;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.welcome-privacy {
|
||||
margin: 22px 0 0;
|
||||
max-width: 42em;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.welcome-trouble {
|
||||
margin: 18px 0 0;
|
||||
max-width: 36em;
|
||||
color: var(--danger-ink);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* The first sync. A bar rather than a spinner, because there is a real denominator, and a thin one
|
||||
because it is a fact about the wait rather than the thing being waited for. */
|
||||
.welcome-bar {
|
||||
width: 280px;
|
||||
height: 3px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-wash);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-bar-fill {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
transition: transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
.welcome-count {
|
||||
margin: 12px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:root[data-phone] .welcome-bar {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/* The add-account flow: the address on the same centred stage as the welcome, then the sign-in
|
||||
panel, and the servers in a sheet over it. The type, the ink and the paragraph widths are the
|
||||
welcome screen's own and are shared with it rather than restated, so the panels are one screen.
|
||||
What is here is the arrangement of the pieces, plus the two columns the sheet needs. */
|
||||
|
||||
/* The address, which is the whole of the first step: one field and one button, each as wide as
|
||||
the column they are in and so as wide as each other, so the two read as one gesture. On the
|
||||
stage the button is larger than a button anywhere else in the app, for the same reason it is the
|
||||
only one there, and the field is a size up with it. In a sheet they are the panel's ordinary
|
||||
size and the panel's full width, like every other field in one. */
|
||||
.imap-address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 26em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.imap-address .field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome .imap-address .field-input {
|
||||
padding: 9px 12px;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.welcome .imap-address .button {
|
||||
min-height: 38px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.sheet .imap-address {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* The button, the field's width, and while a lookup is out the way to stop it beside it at its
|
||||
own. */
|
||||
.imap-go {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imap-go .button[data-variant="primary"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The way to skip the lookup, under everything else on the address step: a ghost button in a
|
||||
paragraph of its own, so it reads as an aside rather than as a second way in. */
|
||||
.imap-aside {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.imap-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 26em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.imap-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Centred only on the stage, which is a column of centred text. Settings hosts the same flow in a
|
||||
sheet, where everything else in the panel starts at the left edge and a row of buttons floating
|
||||
in the middle would be the only thing that did not. */
|
||||
.welcome .imap-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* The same panels in a sheet. On the stage each of these paragraphs holds itself off whatever is
|
||||
above it, because a centred column has no other spacing; a panel body is a flex column with a gap
|
||||
already, so the margins would be counted twice. Nothing else about the flow changes between the
|
||||
two hosts, which is the point of it being one component. */
|
||||
.sheet .welcome-line,
|
||||
.sheet .welcome-privacy,
|
||||
.sheet .welcome-trouble,
|
||||
.sheet .imap-aside {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The one thing a left edge exposes that a centred column hid. A ghost button carries the same
|
||||
horizontal padding as a filled one, so on its own on a line it starts a button's padding in from
|
||||
everything above it, and the field that does start at the edge is right there to compare it
|
||||
with. Pulled back by exactly that padding rather than by an eyeballed number. */
|
||||
.sheet .imap-aside .button {
|
||||
margin-left: -12px;
|
||||
}
|
||||
|
||||
/* A name and a value: the two servers on the panel that says what was found, and the four facts on
|
||||
a certificate. The names line up in a column narrow enough that the values still read as a list
|
||||
rather than as a table. */
|
||||
.imap-facts {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 34em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* On the stage the list sits between a paragraph and the form, neither of which is a sheet's
|
||||
evenly spaced body, so it carries its own room, and it is the form's width so the two share a
|
||||
left edge in the centred column. */
|
||||
.welcome .imap-facts {
|
||||
margin: 6px 0 20px;
|
||||
max-width: 26em;
|
||||
}
|
||||
|
||||
.imap-fact {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5em 1fr;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.imap-fact dt {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.imap-fact dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-size: var(--t-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Somebody else's string, which is worth being able to select and paste. */
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* A fingerprint is sixty-four characters that have to be compared against another sixty-four, so
|
||||
it wraps wherever it must rather than pushing the panel wider than the window. */
|
||||
.imap-fact dd[data-wrap] {
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.imap-lead {
|
||||
margin: 0;
|
||||
font-size: var(--t-4);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.imap-note {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* The server's own words, verbatim. They are usually written for whoever runs the server rather
|
||||
than for the person holding the laptop, which is why the sentence above them is ours. */
|
||||
.imap-said {
|
||||
margin: -6px 0 0;
|
||||
color: var(--danger-ink);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.imap-advice {
|
||||
margin: -6px 0 0;
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.imap-legs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.imap-leg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.imap-leg-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
/* The sheet's buttons live in its foot, outside the form, so this is what Enter presses. */
|
||||
.imap-enter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Two columns of five fields do not fit beside each other on a phone, and stacking them keeps the
|
||||
order they are asked in: everything about incoming mail, then everything about outgoing. */
|
||||
:root[data-phone] .imap-legs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:root[data-phone] .imap-fact {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/* The contact card and the Contacts place.
|
||||
*
|
||||
* The card's frame, its rows and its two-column rhythm are the Popover primitive's; what is here is
|
||||
* only what a card of decisions adds to it: the picker, the note, and the two quiet lists at its
|
||||
* foot. The place borrows the list column's head and the mail list's row, so a person is read the
|
||||
* same way a thread is. */
|
||||
|
||||
/* -- the card ---------------------------------------------------------------------------------- */
|
||||
|
||||
.contact-who {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Before the card has arrived. A popover that measured zero would place itself, then jump when the
|
||||
answer came back, so it holds a card's worth of height while it waits, and three faint lines
|
||||
breathe in it so the wait reads as a wait rather than as a card with nothing on it. The lines
|
||||
are the pane's pending body, drawn at a card's width. */
|
||||
.contact-waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 120px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.contact-waiting span {
|
||||
height: 9px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.contact-waiting[data-phase="loading"] span {
|
||||
animation: contact-waiting 1100ms var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
.contact-waiting span:nth-child(1) {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.contact-waiting span:nth-child(2) {
|
||||
width: 86%;
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
.contact-waiting span:nth-child(3) {
|
||||
width: 64%;
|
||||
animation-delay: 280ms;
|
||||
}
|
||||
|
||||
@keyframes contact-waiting {
|
||||
to {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contact-waiting[data-phase="loading"] span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A switch is its own row: the label is on the left and the track on the right, which is the same
|
||||
rhythm as a label and its value and needs nothing between them. */
|
||||
.contact-toggle {
|
||||
padding: 2px 16px;
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* The one control on this card that changes where somebody's mail goes. Drawn as the row's value
|
||||
rather than as a button, because it is a value: it says where the mail delivers and it is also
|
||||
how that is changed. The platform's own picker chrome is dropped and the app's is put back, so
|
||||
the chevron beside it is the same drawing as every other chevron here. */
|
||||
.contact-picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
color: var(--ink-faint);
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.contact-picker:hover,
|
||||
.contact-picker:focus-within {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.contact-picker .icon {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.contact-pick {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 2px 24px 2px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.contact-note-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.contact-note-row .lab {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* A note reads as what it says until somebody reaches for it, and then as the field it is. A note
|
||||
is yours and not the sender's, so the surface it takes while it is being written is the same one
|
||||
a note takes on a row. */
|
||||
.contact-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.45;
|
||||
resize: none;
|
||||
transition: background 120ms var(--ease), border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.contact-note::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.contact-note:hover,
|
||||
.contact-note:focus {
|
||||
border-color: var(--note-line);
|
||||
background: var(--note-surface);
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.contact-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 3px 16px;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.contact-line {
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
button.contact-line:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.contact-line-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-line-side {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
}
|
||||
|
||||
.contact-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.contact-unsub {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
transition: color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.contact-unsub:hover {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* Pressed and waiting on the sender's server. The label has already changed to say so; the pulse
|
||||
is what keeps it from reading as a dead control while a slow host answers. */
|
||||
.contact-unsub[data-phase="unsubscribing"] {
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
animation: contact-busy 1.2s var(--ease) infinite;
|
||||
}
|
||||
|
||||
.contact-unsub[data-phase="unsubscribing"]:hover {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@keyframes contact-busy {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contact-unsub[data-phase="unsubscribing"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- the place ---------------------------------------------------------------------------------- */
|
||||
|
||||
.contacts {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contacts-head {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.contacts-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--raised);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.contacts-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.contacts-search input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.contacts-list {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Asked again, with the last answer still on it. The whole list breathes rather than each row,
|
||||
because a list of two hundred people is two hundred animations otherwise, and a quick answer
|
||||
never gets far enough into the first breath to be seen at all. */
|
||||
.contacts-list[data-phase="loading"] {
|
||||
animation: contacts-looking 1100ms var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
/* The first open, before any answer: the row's shape, faintly, where the first rows will be. */
|
||||
.contacts-waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
.contacts-waiting span {
|
||||
height: 9px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.contacts-waiting span:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.contacts-waiting span:nth-child(3) {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
@keyframes contacts-looking {
|
||||
to {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* What went wrong, in the list's own quiet voice, above whatever was last on it. */
|
||||
.contacts-note {
|
||||
margin: 0;
|
||||
padding: 14px 24px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contacts-list[data-phase="loading"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The mail list's row, with the two decisions worth changing without opening anything beside it. */
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
/* The row keeps a measure of its own rather than stretching to the window, so the date stays with
|
||||
the name it belongs to instead of drifting to the far edge of a wide screen. */
|
||||
.contact-item .row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.contact-controls {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.contact-controls .contact-picker {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
/* There is no room for a picker and a switch beside a row on a phone, and the card is one tap away
|
||||
on the row itself. */
|
||||
:root[data-phone] .contact-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-phone] .contacts-search {
|
||||
width: auto;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* The message body, in the text face at the reading size.
|
||||
*
|
||||
* The editor is the only place in the app where what you are writing and what the reader will get
|
||||
* have to look like the same thing, so it takes the same face and the same measure a message body
|
||||
* is rendered in rather than the interface face everything around it uses. */
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
color: var(--ink);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-body > * {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.editor-body > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-body a {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.editor-body ul,
|
||||
.editor-body ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.editor-body li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-body blockquote {
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.editor-body code {
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.editor-body pre {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--accent-wash);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* One line of grey text under the caret until something is typed. It sits behind the document
|
||||
rather than inside it, so nothing about the placeholder can end up in what is sent. */
|
||||
.editor-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor:not([data-empty]) .editor-placeholder {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* The Feed: the whole stage, one column of cards on the Feed's own measure.
|
||||
*
|
||||
* A card is not a row that opens a message, it is the message, so the geometry is a page's rather
|
||||
* than a list's: a head, a title in the text face, the body, and a foot of verbs. */
|
||||
|
||||
.feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 22px 40px 40px;
|
||||
}
|
||||
|
||||
.feed-inner {
|
||||
max-width: var(--feed-w);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* The banner is the top of the column rather than a strip over it, so it takes the column's gap
|
||||
instead of a margin of its own. */
|
||||
.feed-inner > .banner {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--raised);
|
||||
overflow: hidden;
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
/* The card the keyboard is on, the same ring the Screener's cards take. */
|
||||
.feed-card[data-selected] {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--card-ring);
|
||||
}
|
||||
|
||||
.feed-head {
|
||||
display: grid;
|
||||
grid-template-columns: var(--avatar) 1fr auto;
|
||||
gap: 0 12px;
|
||||
align-items: center;
|
||||
padding: 14px 18px 0;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
margin: 10px 18px 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* A card whose body has not arrived yet carries the pane's skeleton where the body will go, or the
|
||||
one line that says it did not come, on the body's own inset. Nothing spins. */
|
||||
.feed-wait {
|
||||
padding: 6px 18px 8px;
|
||||
}
|
||||
|
||||
/* `--feed-body-h` is the frame's real content height, written on the element by the card.
|
||||
*
|
||||
* An iframe never lays out shorter than its own box, so a two paragraph newsletter measures as the
|
||||
* frame's default height and MessageBody sizes it to that; the blank tail is clipped out here
|
||||
* rather than argued with there. The max-height is on the content box so the clip is a length of
|
||||
* prose rather than a length of prose plus this file's padding. */
|
||||
.feed-body {
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
max-height: min(var(--feed-clip), var(--feed-body-h, var(--feed-clip)));
|
||||
padding: 6px 18px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feed-body[data-open] {
|
||||
max-height: var(--feed-body-h, none);
|
||||
}
|
||||
|
||||
/* The clip is a fade rather than a cut, because a sentence sliced through the middle reads as a
|
||||
rendering fault and a fade reads as a decision. */
|
||||
/* Inset to the body's own padding rather than the card's edge, because the surface it fades into
|
||||
is the message's and the message starts where the padding ends. */
|
||||
.feed-fade {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 0;
|
||||
height: 70px;
|
||||
background: linear-gradient(to bottom, transparent, var(--raised));
|
||||
}
|
||||
|
||||
/* A body that painted its own page keeps it in both palettes, so in dark the fade has to end on
|
||||
the paper it is fading into rather than on the card behind it. */
|
||||
.feed-body[data-paper] .feed-fade {
|
||||
background: linear-gradient(to bottom, transparent, var(--message-paper));
|
||||
}
|
||||
|
||||
.feed-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 10px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.feed-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Where the last visit ended. A hairline with a label in it rather than a banner: it marks a place
|
||||
in the column and has nothing to say. */
|
||||
.left-off {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: -8px 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.left-off::before,
|
||||
.left-off::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
:root[data-phone] .feed {
|
||||
padding: 14px 12px 24px;
|
||||
}
|
||||
|
||||
:root[data-phone] .feed-inner {
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/* Focus & Reply: the whole stage, a column of items on a measure of its own.
|
||||
*
|
||||
* Wider than the reading pane's, because an item is two columns rather than one: what was written
|
||||
* to you on the left and what you are writing back on the right, so both of them get a measure a
|
||||
* paragraph can live on. */
|
||||
|
||||
.focus {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* This element is the stage as well as the page, and the stage is a flex row. Declaring the
|
||||
column here is what keeps the items stacked and, more to the point, keeps them from being
|
||||
shrunk to the height of the window instead of scrolling past it. */
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 40px 40px 60px;
|
||||
}
|
||||
|
||||
.focus-inner {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: var(--focus-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.focus-head {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.focus-title {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.focus-lede {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
/* The keys, right aligned under the sentence rather than centred with it: it is a legend for the
|
||||
page below rather than part of the title. */
|
||||
.focus-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin: 22px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* One card, two columns, a hairline down the middle. */
|
||||
.focus-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
/* Paper rather than the raised surface, because a message body is rendered in its own document
|
||||
on the paper colour and a card a shade off it would draw a rectangle round every message. */
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.focus-item[data-active] {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--card-ring);
|
||||
}
|
||||
|
||||
.focus-message {
|
||||
min-width: 0;
|
||||
padding: 22px 24px 24px;
|
||||
}
|
||||
|
||||
.focus-subject {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.focus-from {
|
||||
display: grid;
|
||||
grid-template-columns: var(--avatar) 1fr auto;
|
||||
gap: 0 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.focus-who {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.focus-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--t-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.focus-name .addr {
|
||||
margin-left: 6px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.focus-to,
|
||||
.focus-time {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.focus-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.focus-body {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* Before the body has arrived, and where it never will: the snippet is what the list already knew
|
||||
about this thread, and it is enough to recognise it by. */
|
||||
.focus-snippet {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.focus-reply {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.focus-reply-head {
|
||||
flex: none;
|
||||
padding: 13px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.focus-reply-head b {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* The box is the reply, so it takes the height the message beside it needs and grows no scrollbar
|
||||
of its own until it has to. */
|
||||
.focus-box {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
padding: 16px 18px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.focus-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.focus-box::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.focus-reply-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* An item that has been answered is one line: the page shortens as it is worked through, which is
|
||||
the only progress this screen reports. */
|
||||
.focus-sent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.focus-sent b {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.focus-sent .icon {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.focus-blank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.focus-foot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* On a phone the two columns are one: a message and then the box under it, which is the order you
|
||||
read them in anyway. */
|
||||
:root[data-phone] .focus {
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
:root[data-phone] .focus-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:root[data-phone] .focus-reply {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// The pure half of what the screens print. These are the functions with a right answer rather than
|
||||
// a look, so they are asserted here instead of in the browser suite.
|
||||
//
|
||||
// `format.ts` reaches for a DOMParser in `previewOf` and for the binding table in `cap`, and it
|
||||
// does both at call time, so importing the module in node is safe and the rest of it can be tested
|
||||
// without a document.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { displayName, participantLine } from "./format";
|
||||
|
||||
describe("participantLine", () => {
|
||||
it("says nothing about nobody", () => {
|
||||
expect(participantLine([], false)).toBe("");
|
||||
});
|
||||
|
||||
it("names one person as themselves", () => {
|
||||
expect(participantLine(["City Power"], false)).toBe("City Power");
|
||||
});
|
||||
|
||||
it("puts you last, and only when you wrote in the thread", () => {
|
||||
expect(participantLine(["Arun Kulkarni"], true)).toBe("Arun Kulkarni and you");
|
||||
expect(participantLine(["Arun Kulkarni"], false)).toBe("Arun Kulkarni");
|
||||
});
|
||||
|
||||
it("joins up to four in full, because a count would be longer than the name", () => {
|
||||
expect(participantLine(["Arun", "Maya", "Karthik", "Priya"], false)).toBe(
|
||||
"Arun, Maya, Karthik and Priya",
|
||||
);
|
||||
expect(participantLine(["Arun", "Maya", "Karthik"], true)).toBe("Arun, Maya, Karthik and you");
|
||||
});
|
||||
|
||||
it("names three of a crowd and counts the rest", () => {
|
||||
const forty = Array.from({ length: 40 }, (_, i) => `Person ${i + 1}`);
|
||||
expect(participantLine(forty, false)).toBe("Person 1, Person 2, Person 3 and 37 others");
|
||||
});
|
||||
|
||||
it("counts you among the others rather than losing the count to you", () => {
|
||||
const forty = Array.from({ length: 40 }, (_, i) => `Person ${i + 1}`);
|
||||
expect(participantLine(forty, true)).toBe("Person 1, Person 2, Person 3 and 38 others");
|
||||
});
|
||||
|
||||
it("never runs past a line, however many people are in the thread", () => {
|
||||
const many = Array.from({ length: 400 }, (_, i) => `Somebody With A Long Name ${i}`);
|
||||
expect(participantLine(many, true).length).toBeLessThan(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayName", () => {
|
||||
it("reads a sorting key back the way it was written", () => {
|
||||
expect(displayName({ name: "Young, Russell", address: "[email protected]" })).toBe("Russell Young");
|
||||
});
|
||||
|
||||
it("leaves a name with two commas alone, because it is not a sorting key", () => {
|
||||
expect(displayName({ name: "Beale, Jones and Fry, LLP", address: "[email protected]" })).toBe(
|
||||
"Beale, Jones and Fry, LLP",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the address when there is no name", () => {
|
||||
expect(displayName({ name: null, address: "[email protected]" })).toBe(
|
||||
"[email protected]",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// What the screens print. Dates, sizes, names and the one guess this package has to make.
|
||||
//
|
||||
// None of it is state and none of it touches a store, so it is a module of functions rather than a
|
||||
// hook: the list, the pane and the piles all want the same short time string and none of them
|
||||
// should own it.
|
||||
|
||||
import { keyFor, keyLabel, type CommandId } from "../keys/bindings";
|
||||
import type { Person } from "../ipc";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const clock = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
const dayMonth = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
||||
|
||||
const midnight = (ms: number): number => {
|
||||
const d = new Date(ms);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
/** Whole days between two instants, counted by calendar day rather than by elapsed hours. */
|
||||
const daysAgo = (ms: number, now: number): number =>
|
||||
Math.round((midnight(now) - midnight(ms)) / DAY_MS);
|
||||
|
||||
/**
|
||||
* The time on a row: the hour today, the word Yesterday, the weekday inside the week, the date
|
||||
* after that. A row has one line for it and no room to say the year.
|
||||
*/
|
||||
export function rowTime(ms: number, now = Date.now()): string {
|
||||
const days = daysAgo(ms, now);
|
||||
if (days <= 0) return clock.format(ms);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return weekday.format(ms);
|
||||
return dayMonth.format(ms);
|
||||
}
|
||||
|
||||
/** The time on a message, which has room to say both the day and the hour. */
|
||||
export function messageTime(ms: number, now = Date.now()): string {
|
||||
const days = daysAgo(ms, now);
|
||||
if (days <= 0) return `Today ${clock.format(ms)}`;
|
||||
if (days === 1) return `Yesterday ${clock.format(ms)}`;
|
||||
if (days < 7) return `${weekday.format(ms)} ${clock.format(ms)}`;
|
||||
return dayMonth.format(ms);
|
||||
}
|
||||
|
||||
/** Binary, because that is what a mail client and an operating system both mean by KB here. */
|
||||
export function fileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** The mark on an attachment chip: the extension, or the subtype when there is no extension. */
|
||||
export function fileKind(filename: string, mimeType: string): string {
|
||||
const dot = filename.lastIndexOf(".");
|
||||
const ext = dot > 0 ? filename.slice(dot + 1) : mimeType.slice(mimeType.indexOf("/") + 1);
|
||||
return ext.slice(0, 4).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to print. A header that says "Young, Russell" is a sorting key that escaped from an
|
||||
* address book, and reading it back the way it was written is the whole of the fix.
|
||||
*/
|
||||
export function displayName(person: Person): string {
|
||||
const name = person.name?.trim();
|
||||
if (!name) return person.address;
|
||||
const comma = name.indexOf(",");
|
||||
if (comma > 0 && name.indexOf(",", comma + 1) === -1) {
|
||||
const last = name.slice(0, comma).trim();
|
||||
const first = name.slice(comma + 1).trim();
|
||||
if (first && last) return `${first} ${last}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** A hue token name (`hue-4`) as the number the primitives take. */
|
||||
export function hueOf(color: string): number | undefined {
|
||||
const n = Number(color.replace(/^hue-/, ""));
|
||||
return Number.isFinite(n) && n >= 1 && n <= 8 ? n : undefined;
|
||||
}
|
||||
|
||||
const CONSUMER_DOMAINS = new Set([
|
||||
"gmail.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"yahoo.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"hey.com",
|
||||
"example.com",
|
||||
]);
|
||||
|
||||
const domainOf = (address: string): string => address.slice(address.indexOf("@") + 1).toLowerCase();
|
||||
const localOf = (address: string): string => address.slice(0, address.indexOf("@")).toLowerCase();
|
||||
|
||||
/**
|
||||
* Whether to draw a company's bordered mark rather than a person's coloured initials.
|
||||
*
|
||||
* This is a guess, and it is a guess because nothing on `ThreadSummary` says which senders are
|
||||
* companies: the contract carries a `Person` and a `Person` is a name and an address. The rule is
|
||||
* that a person's address is built out of their name, which is true of `maya.raghunathan@` and
|
||||
* false of `no-reply@` and `customerservice@`, and that a name of more than two words is a company
|
||||
* whatever its address says. It gets Northwind Payroll wrong, and a real answer needs a fact from
|
||||
* the contact record rather than a better regular expression.
|
||||
*/
|
||||
export function isBrand(person: Person): boolean {
|
||||
const name = person.name?.trim();
|
||||
if (!name) return false;
|
||||
if (CONSUMER_DOMAINS.has(domainOf(person.address))) return false;
|
||||
const words = displayName(person).split(/\s+/).filter(Boolean);
|
||||
if (words.length > 2) return true;
|
||||
const local = localOf(person.address).replace(/[^a-z]/g, "");
|
||||
// An address that is somebody's initials is still somebody's address: pj@ is a person and dse@
|
||||
// is DocuSign, and the only thing telling them apart is that one spells the name and one does not.
|
||||
if (local === words.map((w) => w[0].toLowerCase()).join("")) return false;
|
||||
return !words.some((word) => {
|
||||
const w = word.toLowerCase().replace(/[^a-z]/g, "");
|
||||
return w.length > 2 && local.includes(w);
|
||||
});
|
||||
}
|
||||
|
||||
/** The rendered text of a sanitised body, for the one line a collapsed message shows. */
|
||||
export function previewOf(html: string): string {
|
||||
// Parsed rather than stripped with a regular expression, so entities come back as characters.
|
||||
// A DOMParser document is inert: nothing in it runs and nothing in it is fetched.
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* How many people the line names before it starts counting them. Four are still printed in full,
|
||||
* because "and 1 other" is longer than the name it would stand in for.
|
||||
*/
|
||||
const NAMED = 3;
|
||||
|
||||
/**
|
||||
* The participants line: everyone in the thread, with you last and only when you wrote in it.
|
||||
*
|
||||
* A receipt from City Power is addressed to you and saying "City Power and you" about it is noise;
|
||||
* a thread you answered is a conversation and leaving yourself out of it reads as though somebody
|
||||
* else did the answering.
|
||||
*
|
||||
* Past four it counts instead. A calendar invite to a floor of forty is one of the commonest things
|
||||
* in a work mailbox, and forty names is not a line, it is a column: it takes the height of the
|
||||
* whole head and pushes the message under the fold. Nobody reads the fortieth name.
|
||||
*/
|
||||
export function participantLine(names: string[], includesYou: boolean): string {
|
||||
const parts = includesYou ? [...names, "you"] : names;
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
if (parts.length > NAMED + 1) {
|
||||
return `${parts.slice(0, NAMED).join(", ")} and ${parts.length - NAMED} others`;
|
||||
}
|
||||
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a button prints. An unmodified letter is printed as the letter, which is how the app
|
||||
* writes `r` on Reply; anything with a modifier is printed in real glyphs, which is how it writes
|
||||
* ⇧N on Notify. The binding table is the only source for either.
|
||||
*/
|
||||
export function cap(command: CommandId): string | undefined {
|
||||
const combo = keyFor(command);
|
||||
if (!combo) return undefined;
|
||||
return combo.includes("+") ? keyLabel(combo) : combo;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number in an account's hue token. `Account.color` is a token name (`hue-4`) rather than a hex,
|
||||
* because the stylesheet owns the value and the account only owns the choice.
|
||||
*/
|
||||
export function accountHue(color: string): number | undefined {
|
||||
const match = /^hue-([1-8])$/.exec(color);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/* The guide: search across the top, then a rail of articles on the left and one article on the
|
||||
right at a measure prose is read at, inside the panel that takes the window. The shape below the
|
||||
search is Settings', and the two files agree on purpose: a person who has been in one of them
|
||||
should not have to work out the other. Layout only, every value through a token.
|
||||
|
||||
The panel's own head carries the title and the close control, so the guide starts at the search
|
||||
field, which runs the width of the panel above both columns rather than sitting in the corner of
|
||||
one of them. */
|
||||
|
||||
.guide {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The two columns, under the search that narrows one of them. */
|
||||
.guide-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.guide-rail {
|
||||
flex: none;
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 12px 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--sidebar);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The search bar. A band across the panel with a rule under it rather than a box inside a column:
|
||||
it is the head of the whole guide and not a control belonging to the rail, and a boxed field
|
||||
floating in a band that wide would read as one. It is the size of the thing it is: the type is
|
||||
the phone field size, which is the largest the app sets, so the bar is the first thing the eye
|
||||
lands on under the title. */
|
||||
.guide-search {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink-faint);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.guide-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-5);
|
||||
}
|
||||
|
||||
.guide-search input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.guide-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The engine's own clear control for a search field, which is drawn in nobody's palette and sits
|
||||
where the rest of the app puts nothing. The field clears by deleting what is in it. */
|
||||
.guide-search input::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* A search with no answer: under the field that asked, across the panel, because it is the answer
|
||||
to the search rather than a state of the rail. The rail below it is empty and the article that
|
||||
was open is still open, which is the point: nothing was taken away, there is just nothing more. */
|
||||
.guide-nothing {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 26px 22px 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The family's empty line without the room a list gives it: here it is the head of a short block
|
||||
rather than the whole of what a column has to say. */
|
||||
.guide-nothing .empty-state {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.guide-ask {
|
||||
max-width: var(--measure);
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The section labels in the rail are the family's group head, so a group of articles is read the
|
||||
same way a group of threads is. What they lose is a list's horizontal padding, which is drawn for
|
||||
a 420px column and not for this one. */
|
||||
.guide-rail .group-head {
|
||||
padding: 12px 8px 4px;
|
||||
}
|
||||
|
||||
.guide-tab {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.guide-tab:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.guide-tab[data-active] {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.guide-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 48px;
|
||||
}
|
||||
|
||||
/* The text face at the reading size. The measure is on the prose rather than on the article,
|
||||
because a picture is not prose: the sentences hold to the same 46em the reading pane holds to,
|
||||
and a picture of the app is allowed the whole column, which is the only way it is drawn at the
|
||||
size it was taken at. */
|
||||
.guide-article {
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.guide-eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.guide-title {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.guide-p {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 14px;
|
||||
color: var(--ink);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.guide-steps {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 14px;
|
||||
padding-left: 22px;
|
||||
color: var(--ink);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.guide-steps li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* There are no keys on a phone, and the space in front of one goes with it. The cap itself is
|
||||
hidden by the primitive; what this rule takes away is the gap it would leave in a sentence. */
|
||||
:root[data-phone] .guide-cap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* One line set apart from the prose: the thing people get wrong, or the promise worth saying
|
||||
twice. A rule down the left rather than a box, because a box in the middle of a page reads as a
|
||||
warning and almost none of these are one. */
|
||||
.guide-note {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 14px;
|
||||
padding: 2px 0 2px 14px;
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The verbs of a thing as a table. A paragraph that lists six keys is a paragraph nobody reads. */
|
||||
.guide-keys {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 16px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 7px 14px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.guide-key {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.guide-key dt {
|
||||
margin: 0;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.guide-key dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* A drawn figure is not a photograph and does not want a photograph's frame: it is made of the
|
||||
app's own parts, so it sits on the paper the way the app does. */
|
||||
.guide-figure[data-drawn] {
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.guide-link {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
transition: text-decoration-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.guide-link:hover {
|
||||
text-decoration-color: var(--ink);
|
||||
}
|
||||
|
||||
/* A picture is drawn at the size it was, which the `img` carries, and the only thing left here is
|
||||
what to do when the column is narrower than that: shrink it and keep its shape. A window shot is
|
||||
1440 wide and the column is not, so that case is every window in the guide. */
|
||||
.guide-figure {
|
||||
margin: 20px 0 22px;
|
||||
}
|
||||
|
||||
.guide-figure img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
|
||||
.guide-figure figcaption {
|
||||
max-width: var(--measure);
|
||||
margin-top: 8px;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* On a phone the rail sits above the article rather than beside it, and keeps its shape: 260px of
|
||||
names next to a column of prose is the whole screen twice over, and a strip of fifty names with
|
||||
the section labels dropped out of it is a map with the countries rubbed off. */
|
||||
:root[data-phone] .guide-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:root[data-phone] .guide-rail {
|
||||
flex: none;
|
||||
width: auto;
|
||||
max-height: 40dvh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
:root[data-phone] .guide-search {
|
||||
padding: 9px 16px;
|
||||
}
|
||||
|
||||
:root[data-phone] .guide-nothing {
|
||||
padding: 20px 16px 22px;
|
||||
}
|
||||
|
||||
:root[data-phone] .guide-panel {
|
||||
padding: 18px 16px 40px;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Fragment } from "react";
|
||||
import { Key } from "../../ui";
|
||||
import { labelFor, type CommandId } from "../../keys/bindings";
|
||||
import type { Place } from "../../ipc";
|
||||
import { useMail } from "../../store/useMail";
|
||||
import { useOverlays } from "../../store/useOverlays";
|
||||
import { useSettings } from "../../store/useSettings";
|
||||
import { cap } from "../format";
|
||||
import { sectionOf, titleOf } from "./content";
|
||||
import { Figure } from "./Figures";
|
||||
import { PICTURES, type Article, type Block, type Piece } from "./types";
|
||||
|
||||
/**
|
||||
* One article: a heading, prose, sometimes numbered steps, sometimes one picture.
|
||||
*
|
||||
* The markup is here and the words are in the section files, which is what lets the filter search
|
||||
* what an article says without rendering it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A keycap, always the binding table's answer for that verb rather than a letter typed into the
|
||||
* copy, so a remapped key teaches the key it was remapped to. `cap` is what every button in the app
|
||||
* prints: the bare letter when it is unmodified, real glyphs when it is not.
|
||||
*
|
||||
* The space in front of it is inside the span rather than at the end of the sentence before it,
|
||||
* because on a phone the cap is not drawn: a space left behind there prints as "the Paper Trail .
|
||||
* Anything in the wrong one", which is a sentence with a hole in it.
|
||||
*/
|
||||
function Cap({ of }: { of: CommandId }) {
|
||||
const key = cap(of);
|
||||
if (!key) return null;
|
||||
return (
|
||||
<span className="guide-cap">
|
||||
{" "}
|
||||
<Key>{key}</Key>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link out of the guide closes the guide behind it, because the thing it points at is underneath
|
||||
* the panel and a guide left open over it would be one you have to dismiss twice. Coming back is
|
||||
* the question mark in the corner.
|
||||
*/
|
||||
function goToPlace(place: Place): void {
|
||||
useOverlays.getState().close();
|
||||
useMail.getState().goTo(place);
|
||||
}
|
||||
|
||||
function openSettings(): void {
|
||||
useOverlays.getState().close();
|
||||
useSettings.getState().show();
|
||||
}
|
||||
|
||||
function Pieces({ pieces, onOpen }: { pieces: readonly Piece[]; onOpen: (id: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
{pieces.map((piece, at) => {
|
||||
if (typeof piece === "string") return <Fragment key={at}>{piece}</Fragment>;
|
||||
if ("cap" in piece) return <Cap key={at} of={piece.cap} />;
|
||||
if ("see" in piece) {
|
||||
const id = piece.see;
|
||||
return (
|
||||
<button key={at} type="button" className="guide-link" onClick={() => onOpen(id)}>
|
||||
{titleOf(id)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if ("place" in piece) {
|
||||
const place = piece.place;
|
||||
return (
|
||||
<button key={at} type="button" className="guide-link" onClick={() => goToPlace(place)}>
|
||||
{piece.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button key={at} type="button" className="guide-link" onClick={openSettings}>
|
||||
{piece.settings}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockView({ block, onOpen }: { block: Block; onOpen: (id: string) => void }) {
|
||||
if ("p" in block) {
|
||||
return (
|
||||
<p className="guide-p">
|
||||
<Pieces pieces={block.p} onOpen={onOpen} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if ("steps" in block) {
|
||||
return (
|
||||
<ol className="guide-steps">
|
||||
{block.steps.map((step, at) => (
|
||||
<li key={at}>
|
||||
<Pieces pieces={step} onOpen={onOpen} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
if ("note" in block) {
|
||||
return (
|
||||
<p className="guide-note">
|
||||
<Pieces pieces={block.note} onOpen={onOpen} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing in a key table is written by the article: the cap is what the keymap answers to and the
|
||||
// words beside it are the binding's own label, which is what the palette and the shortcut sheet
|
||||
// print for the same verb. A remap moves all three together.
|
||||
if ("keys" in block) {
|
||||
return (
|
||||
<dl className="guide-keys">
|
||||
{block.keys.map((id) => {
|
||||
const key = cap(id);
|
||||
return (
|
||||
<div className="guide-key" key={id}>
|
||||
<dt>{key ? <Key>{key}</Key> : null}</dt>
|
||||
<dd>{labelFor(id)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
if ("figure" in block) {
|
||||
return (
|
||||
<figure className="guide-figure" data-drawn="">
|
||||
<Figure of={block.figure} />
|
||||
<figcaption>{block.caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
// The size the picture was, which is half the pixels it was captured at, on the element rather
|
||||
// than in the stylesheet: it is a fact about that one file, the browser holds the room for it
|
||||
// before it arrives, and the stylesheet is left with the one thing that is a layout decision,
|
||||
// which is what happens when the column is narrower than the picture.
|
||||
const size = PICTURES[block.picture];
|
||||
return (
|
||||
<figure className="guide-figure">
|
||||
<img
|
||||
src={`/guide/${block.picture}.png`}
|
||||
alt={block.alt}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
/>
|
||||
<figcaption>{block.caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticleView({
|
||||
article,
|
||||
onOpen,
|
||||
}: {
|
||||
article: Article;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const section = sectionOf(article.id);
|
||||
return (
|
||||
<article className="guide-article">
|
||||
{section ? <p className="guide-eyebrow">{section.title}</p> : null}
|
||||
<h2 className="guide-title">{article.title}</h2>
|
||||
{article.blocks.map((block, at) => (
|
||||
<BlockView key={at} block={block} onOpen={onOpen} />
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Icon, icons, Key } from "../../ui";
|
||||
import { ACCOUNT_KEYS, keyLabel, type CommandId } from "../../keys/bindings";
|
||||
import { cap } from "../format";
|
||||
import type { FigureId } from "./types";
|
||||
import "./figures.css";
|
||||
|
||||
/**
|
||||
* The drawn figures, one per idea the guide has to explain.
|
||||
*
|
||||
* A picture is the app photographed and a figure is an idea drawn, which is why these are built
|
||||
* from the same tokens and the same primitives as the app rather than exported from a drawing
|
||||
* program: a diagram of the three boxes that stopped looking like the three boxes would be worse
|
||||
* than no diagram, and one that lives in this repository moves when they do.
|
||||
*
|
||||
* Nothing here is decoration. Every figure is making one sentence, and the sentence is under it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A key as the app prints it, and the same key again as bare text.
|
||||
*
|
||||
* `<Key>` is not drawn on a phone, and a figure whose meaning rides on its caps would say nothing
|
||||
* there, so the letter follows it in plain text and the stylesheet shows whichever of the two the
|
||||
* window is for.
|
||||
*/
|
||||
function KeyText({ children, size = "sm" }: { children: string; size?: "sm" | "md" }) {
|
||||
return (
|
||||
<span className="fig-cap">
|
||||
<Key size={size}>{children}</Key>
|
||||
<span className="fig-cap-flat">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The key a verb answers to today, from the binding table, never a letter typed into a figure. */
|
||||
function Cap({ of, size }: { of: CommandId; size?: "sm" | "md" }) {
|
||||
const key = cap(of);
|
||||
return key ? <KeyText size={size}>{key}</KeyText> : null;
|
||||
}
|
||||
|
||||
/** One thread as it reads down a list: the avatar, then the run of words. */
|
||||
function ThreadRow({ state }: { state?: "selected" | "gone" }) {
|
||||
return (
|
||||
<span className="fig-row" data-state={state}>
|
||||
<span className="fig-avatar" />
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="line" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stack of cards at the foot of the list, with the top card's thread showing. */
|
||||
function Pile({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="fig-pile">
|
||||
<span className="fig-pile-edge" />
|
||||
<span className="fig-pile-card">
|
||||
<span className="fig-mark">{label}</span>
|
||||
<span className="fig-bar" data-w="subject" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a message can end up, and the one place among the four that is not a box. */
|
||||
function Routing() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-chip">Mail from a sender you have no rule for</span>
|
||||
<span className="fig-drop" />
|
||||
<span className="fig-gate">
|
||||
Screener
|
||||
<Cap of="place-screener" />
|
||||
</span>
|
||||
<span className="fig-legs">
|
||||
<span className="fig-leg" />
|
||||
<span className="fig-leg" />
|
||||
<span className="fig-leg" />
|
||||
<span className="fig-leg" />
|
||||
</span>
|
||||
<span className="fig-ends">
|
||||
<span className="fig-end">
|
||||
Inbox
|
||||
<Cap of="place-inbox" />
|
||||
</span>
|
||||
<span className="fig-end">
|
||||
Feed
|
||||
<Cap of="place-feed" />
|
||||
</span>
|
||||
<span className="fig-end">
|
||||
Paper Trail
|
||||
<Cap of="place-paper-trail" />
|
||||
</span>
|
||||
<span className="fig-end" data-out="">
|
||||
Screened out
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const BOXES: readonly { command: CommandId; name: string; holds: string }[] = [
|
||||
{ command: "place-inbox", name: "Inbox", holds: "People, and every reply to a thread you are in" },
|
||||
{ command: "place-feed", name: "Feed", holds: "What you subscribed to, drawn open" },
|
||||
{
|
||||
command: "place-paper-trail",
|
||||
name: "Paper Trail",
|
||||
holds: "Receipts, confirmations, what a machine sent",
|
||||
},
|
||||
];
|
||||
|
||||
function Boxes() {
|
||||
return (
|
||||
<>
|
||||
{BOXES.map((box) => (
|
||||
<span className="fig-box" key={box.name}>
|
||||
<span className="fig-box-name">
|
||||
{box.name}
|
||||
<Cap of={box.command} />
|
||||
</span>
|
||||
<span className="fig-box-holds">{box.holds}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The window, at the size a diagram of it is read at rather than the size it is. */
|
||||
function Window() {
|
||||
return (
|
||||
<span className="fig-window">
|
||||
<span className="fig-head">
|
||||
<span className="fig-mark">Header</span>
|
||||
<span className="fig-seg">
|
||||
<span className="fig-seg-on">Inbox</span>
|
||||
<span>Feed</span>
|
||||
<span>Paper Trail</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-stage">
|
||||
<span className="fig-list">
|
||||
<span className="fig-mark">List</span>
|
||||
<ThreadRow state="selected" />
|
||||
<ThreadRow />
|
||||
<span className="fig-piles">
|
||||
<Pile label="Reply later" />
|
||||
<Pile label="Set aside" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-pane">
|
||||
<span className="fig-mark">Reading pane</span>
|
||||
<span className="fig-bar" data-w="subject" data-strong="" />
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="short" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eight verbs as eight single caps, which is the argument: what a figure of the keyboard has to
|
||||
* show is that there is never a second key to hold down with the first.
|
||||
*/
|
||||
const VERBS: readonly { command: CommandId; label: string }[] = [
|
||||
{ command: "archive", label: "Archive" },
|
||||
{ command: "reply-later", label: "Reply later" },
|
||||
{ command: "set-aside", label: "Set aside" },
|
||||
{ command: "snooze", label: "Snooze" },
|
||||
{ command: "toggle-seen", label: "Seen" },
|
||||
{ command: "note", label: "Note" },
|
||||
{ command: "trash", label: "Trash" },
|
||||
{ command: "spam", label: "Spam" },
|
||||
];
|
||||
|
||||
function Keyboard() {
|
||||
return (
|
||||
<>
|
||||
{VERBS.map((verb) => (
|
||||
<span className="fig-verb" key={verb.command}>
|
||||
<Cap of={verb.command} size="md" />
|
||||
<span className="fig-verb-name">{verb.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One card with its parts named, because every part of it is a thing somebody asks about. */
|
||||
function ScreenerCard() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-mark">Who wrote</span>
|
||||
<span className="fig-card-who">
|
||||
<span className="fig-avatar" data-lg="" />
|
||||
<span className="fig-lines">
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="subject" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="fig-mark">What they sent</span>
|
||||
<span className="fig-lines">
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="short" />
|
||||
</span>
|
||||
|
||||
<span className="fig-mark">Why, and where</span>
|
||||
<span className="fig-lines">
|
||||
<span className="fig-tag">Written by a person · suggested Inbox</span>
|
||||
</span>
|
||||
|
||||
<span className="fig-mark">Your three answers</span>
|
||||
<span className="fig-answers">
|
||||
<span className="fig-chip">
|
||||
Yes
|
||||
<Cap of="screen-yes" />
|
||||
</span>
|
||||
<span className="fig-chip">
|
||||
Elsewhere
|
||||
<Cap of="screen-elsewhere" />
|
||||
</span>
|
||||
<span className="fig-chip">
|
||||
No
|
||||
<Cap of="screen-no" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One collapsed message: everything it has to say fits on the line it is given. */
|
||||
function Collapsed() {
|
||||
return (
|
||||
<span className="fig-msg">
|
||||
<span className="fig-avatar" />
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="line" />
|
||||
<span className="fig-bar" data-w="time" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Thread() {
|
||||
return (
|
||||
<span className="fig-thread">
|
||||
<span className="fig-bar" data-w="subject" data-strong="" />
|
||||
<Collapsed />
|
||||
<Collapsed />
|
||||
<span className="fig-msg" data-open="">
|
||||
<span className="fig-msg-head">
|
||||
<span className="fig-avatar" />
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="time" />
|
||||
</span>
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="short" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Trackers() {
|
||||
return (
|
||||
<span className="fig-message">
|
||||
<span className="fig-banner">
|
||||
Blocked 3 trackers. Remote images are off.
|
||||
<span className="fig-banner-action">Show images</span>
|
||||
</span>
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-blocked" />
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="short" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Out of the list and into a pile, and back by the key that put it there. */
|
||||
function Piles() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-list">
|
||||
<ThreadRow />
|
||||
<ThreadRow state="gone" />
|
||||
<ThreadRow />
|
||||
</span>
|
||||
<span className="fig-both-ways">
|
||||
<span className="fig-way">
|
||||
<Cap of="reply-later" />
|
||||
<span className="fig-arrow" />
|
||||
</span>
|
||||
<span className="fig-way">
|
||||
<span className="fig-arrow" data-back="" />
|
||||
<Cap of="reply-later" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-piles">
|
||||
<Pile label="Reply later" />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const WHEN: readonly string[] = [
|
||||
"Later today",
|
||||
"Tomorrow",
|
||||
"This weekend",
|
||||
"Next week",
|
||||
"A date you pick",
|
||||
];
|
||||
|
||||
function Snooze() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-chip">
|
||||
Snooze
|
||||
<Cap of="snooze" />
|
||||
</span>
|
||||
<span className="fig-time">
|
||||
{WHEN.map((when, at) => (
|
||||
<span className="fig-stop" key={when} data-open={at === WHEN.length - 1 ? "" : undefined}>
|
||||
<span className="fig-stop-name">{when}</span>
|
||||
<span className="fig-tick" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="fig-axis">
|
||||
<span>Now</span>
|
||||
<span>Later</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const BAR: readonly { command: CommandId; label: string }[] = [
|
||||
{ command: "reply-later", label: "Reply later" },
|
||||
{ command: "set-aside", label: "Set aside" },
|
||||
{ command: "snooze", label: "Snooze" },
|
||||
{ command: "toggle-seen", label: "Seen" },
|
||||
{ command: "archive", label: "Archive" },
|
||||
{ command: "trash", label: "Trash" },
|
||||
];
|
||||
|
||||
/** A row with the gutter's box ticked, which is the app's own checkbox and its own tick. */
|
||||
function Checked() {
|
||||
return (
|
||||
<span className="fig-row" data-state="selected">
|
||||
<span className="fig-tick-box">
|
||||
<Icon d={icons.CHECK} size={10} />
|
||||
</span>
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="line" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same foot of the same list, before a selection and during one. */
|
||||
function Selection() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-list">
|
||||
<ThreadRow />
|
||||
<ThreadRow />
|
||||
<ThreadRow />
|
||||
<span className="fig-piles">
|
||||
<Pile label="Reply later" />
|
||||
<Pile label="Set aside" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-way">
|
||||
<Cap of="select" />
|
||||
<span className="fig-arrow" />
|
||||
</span>
|
||||
<span className="fig-list">
|
||||
<Checked />
|
||||
<Checked />
|
||||
<ThreadRow />
|
||||
<span className="fig-action-bar">
|
||||
<span className="fig-mark">2 selected</span>
|
||||
<span className="fig-verbs">
|
||||
{BAR.map((verb) => (
|
||||
<span className="fig-chip" key={verb.command}>
|
||||
{verb.label}
|
||||
<Cap of={verb.command} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One thread you owe an answer to, and the box the answer goes in. */
|
||||
function Owed() {
|
||||
return (
|
||||
<span className="fig-item">
|
||||
<span className="fig-lines">
|
||||
<span className="fig-msg-head">
|
||||
<span className="fig-avatar" />
|
||||
<span className="fig-bar" data-w="name" />
|
||||
<span className="fig-bar" data-w="time" />
|
||||
</span>
|
||||
<span className="fig-bar" data-w="wide" />
|
||||
<span className="fig-bar" data-w="short" />
|
||||
</span>
|
||||
<span className="fig-reply">
|
||||
<span className="fig-bar" data-w="line" />
|
||||
<span className="fig-send">
|
||||
Send
|
||||
<Cap of="send" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Focus() {
|
||||
return (
|
||||
<>
|
||||
<Owed />
|
||||
<Owed />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ten seconds a send waits in, drawn as the length of time it is. */
|
||||
function Undo() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-toast">
|
||||
Sent to Maya
|
||||
<span className="fig-toast-action">
|
||||
Undo
|
||||
<Cap of="undo" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-drop" />
|
||||
<span className="fig-line">
|
||||
<span className="fig-chip">
|
||||
Send
|
||||
<Cap of="send" />
|
||||
</span>
|
||||
<span className="fig-clock">
|
||||
<span className="fig-track">
|
||||
<span className="fig-held">Ten seconds</span>
|
||||
</span>
|
||||
<span className="fig-axis">
|
||||
<span>Undo, and the draft comes back</span>
|
||||
<span>It goes</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Merge() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-list">
|
||||
<ThreadRow />
|
||||
<ThreadRow />
|
||||
</span>
|
||||
<span className="fig-brace" />
|
||||
<span className="fig-way">
|
||||
<Cap of="merge" />
|
||||
<span className="fig-arrow" />
|
||||
</span>
|
||||
<span className="fig-merged">
|
||||
<ThreadRow />
|
||||
<span className="fig-banner" data-note="">
|
||||
Merged from 2 threads
|
||||
<span className="fig-banner-action">Unmerge</span>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const REACH: readonly string[] = ["Inbox", "Feed", "Paper Trail", "Screened out", "Spam", "Trash"];
|
||||
|
||||
function SearchReach() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-field">
|
||||
<span className="fig-bar" data-w="query" />
|
||||
<Cap of="search" />
|
||||
</span>
|
||||
<span className="fig-drop" />
|
||||
<span className="fig-zone">
|
||||
<span className="fig-mark">This device, first</span>
|
||||
<span className="fig-zone-body">
|
||||
{REACH.map((place) => (
|
||||
<span className="fig-chip" key={place}>
|
||||
{place}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-drop" data-ask="" />
|
||||
<span className="fig-zone" data-remote="">
|
||||
<span className="fig-mark">The provider, when you ask</span>
|
||||
<span className="fig-zone-body">
|
||||
<span className="fig-chip" data-action="">
|
||||
Search older mail on Gmail
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Storage() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-mailbox">
|
||||
<span className="fig-mailbox-name">Your mailbox, all of it, at the provider</span>
|
||||
<span className="fig-held-mail">
|
||||
<span className="fig-mailbox-name">On this device, the last month</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-axis">
|
||||
<span>Older</span>
|
||||
<span>Now</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MAILBOXES: readonly { name: string; hue: string; key: string }[] = [
|
||||
{ name: "Work", hue: "hue-2", key: ACCOUNT_KEYS[0] },
|
||||
{ name: "Personal", hue: "hue-4", key: ACCOUNT_KEYS[1] },
|
||||
];
|
||||
|
||||
const OWN: readonly string[] = ["Its own places", "Its own rules", "Its own window"];
|
||||
|
||||
function Accounts() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-switcher">
|
||||
{MAILBOXES.map((mailbox, at) => (
|
||||
<span className="fig-menu-row" key={mailbox.name} data-on={at === 0 ? "" : undefined}>
|
||||
<span className="fig-hue" data-hue={mailbox.hue} />
|
||||
{mailbox.name}
|
||||
<KeyText>{keyLabel(mailbox.key)}</KeyText>
|
||||
</span>
|
||||
))}
|
||||
<span className="fig-menu-row" data-apart="">
|
||||
All accounts
|
||||
<Cap of="accounts" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-mailboxes">
|
||||
{MAILBOXES.map((mailbox) => (
|
||||
<span className="fig-mailbox-card" key={mailbox.name} data-hue={mailbox.hue}>
|
||||
<span className="fig-box-name">{mailbox.name}</span>
|
||||
<span className="fig-zone-body">
|
||||
{OWN.map((each) => (
|
||||
<span className="fig-chip" key={each}>
|
||||
{each}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Three switches in a row, all off, and the quiet that is what off means. */
|
||||
function Notify() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-chip">New mail</span>
|
||||
<span className="fig-drop" />
|
||||
<span className="fig-gates">
|
||||
<span className="fig-switch">
|
||||
<span className="fig-toggle">
|
||||
<span className="fig-knob" />
|
||||
</span>
|
||||
<span className="fig-switch-name">
|
||||
This thread
|
||||
<Cap of="notify" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-switch">
|
||||
<span className="fig-toggle">
|
||||
<span className="fig-knob" />
|
||||
</span>
|
||||
<span className="fig-switch-name">This person</span>
|
||||
</span>
|
||||
<span className="fig-switch">
|
||||
<span className="fig-toggle">
|
||||
<span className="fig-knob" />
|
||||
</span>
|
||||
<span className="fig-switch-name">This place</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="fig-drop" data-faint="" />
|
||||
<span className="fig-chip" data-out="">
|
||||
Nothing
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const STAYS: readonly string[] = [
|
||||
"The mail in your window",
|
||||
"Your piles, notes, renames and clips",
|
||||
"Your sender rules",
|
||||
"Every search you run",
|
||||
];
|
||||
|
||||
const LEAVES: readonly string[] = [
|
||||
"Mail, to and from your provider",
|
||||
"Your backup, encrypted here first",
|
||||
];
|
||||
|
||||
function Privacy() {
|
||||
return (
|
||||
<>
|
||||
<span className="fig-side">
|
||||
<span className="fig-mark">Stays on this device</span>
|
||||
{STAYS.map((each) => (
|
||||
<span className="fig-chip" key={each}>
|
||||
{each}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="fig-side" data-leaves="">
|
||||
<span className="fig-mark">Leaves, because it has to</span>
|
||||
{LEAVES.map((each) => (
|
||||
<span className="fig-chip" key={each}>
|
||||
{each}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FIGURES: Record<FigureId, () => ReactElement> = {
|
||||
routing: Routing,
|
||||
boxes: Boxes,
|
||||
window: Window,
|
||||
keyboard: Keyboard,
|
||||
"screener-card": ScreenerCard,
|
||||
thread: Thread,
|
||||
trackers: Trackers,
|
||||
piles: Piles,
|
||||
snooze: Snooze,
|
||||
selection: Selection,
|
||||
focus: Focus,
|
||||
undo: Undo,
|
||||
merge: Merge,
|
||||
"search-reach": SearchReach,
|
||||
storage: Storage,
|
||||
accounts: Accounts,
|
||||
notify: Notify,
|
||||
privacy: Privacy,
|
||||
};
|
||||
|
||||
export function Figure({ of }: { of: FigureId }) {
|
||||
const Drawn = FIGURES[of];
|
||||
return (
|
||||
<div className="fig" data-fig={of}>
|
||||
<Drawn />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Figure;
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/**
|
||||
* The accounts and the machine they sit on, which is one subject in five questions: how another
|
||||
* mailbox is added, how several share one window, what each account was allowed to do, how much of
|
||||
* it is here, and what of it ever leaves.
|
||||
*/
|
||||
export const ACCOUNTS: Section = {
|
||||
id: "accounts",
|
||||
title: "Accounts",
|
||||
articles: [
|
||||
{
|
||||
id: "acct-add",
|
||||
title: "Add another account",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Add account is at the foot of the Accounts section in ",
|
||||
{ settings: "Settings" },
|
||||
". It asks for the address and nothing else, and works the rest out from the domain.",
|
||||
],
|
||||
},
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
"Type the address. A Google address hands over to your browser, where you sign in with Google; any other address gets a sign-in step with the servers it found and a password field.",
|
||||
],
|
||||
[
|
||||
"Choose how far back this device should keep for that account. The first sync reads the answer, so a year of a busy mailbox is a wait you agreed to.",
|
||||
],
|
||||
[
|
||||
"Wait for the first pass. It says what it is doing and counts the messages in, then opens that account's Inbox with the senders it screened in.",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "settings",
|
||||
alt: "Settings with its rail of twelve sections down the left and one section, Appearance, on the right",
|
||||
caption: "Settings is a place with a rail of sections, and Accounts is the first of them.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Remove an account is in the same section. It takes that account's mail and decisions off this computer and leaves the mailbox at your provider as it was.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Removing a Google account revokes access for all three Margin apps on every machine, because they share one sign-in.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "acct-switch",
|
||||
title: "Switch accounts, and see them all at once",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"The account chip at the left of the header is the switcher. It lists your accounts and All accounts",
|
||||
{ cap: "accounts" },
|
||||
", and prints the key beside each of them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "accounts",
|
||||
caption: "Each account keeps its own everything, and All accounts is all of them in one list.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"An account has its own places, sender rules, piles, Screener, storage window and permissions, so a work account can keep a year while a personal one keeps a month.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"All accounts merges every account's version of the place you are in. Each row carries a coloured edge for the account it came from, the colour on that account's card in ",
|
||||
{ settings: "Settings" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Writing picks the account from the thread you are replying to, or from the one you are looking at. The From field changes it.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "acct-permissions",
|
||||
title: "Permissions",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"A Google account's card in ",
|
||||
{ settings: "Settings" },
|
||||
", under Accounts, lists what it granted, one line each: your mail, your mail settings, your contacts, the people you have written to, backup, and calendar.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Sign-in happens in your browser with Google. This app never sees your password, and you can revoke the key it was given from your Google account at any time.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Any of them can be missing, because consent is granular. A line that is missing says what it costs in plain terms rather than naming a scope, and carries a Grant button.",
|
||||
],
|
||||
},
|
||||
{
|
||||
steps: [
|
||||
["Open the account's card in Settings."],
|
||||
["Press Grant on the line that is missing."],
|
||||
["The consent page opens for the whole list, and the line reads as granted when you come back."],
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Calendar is the one deliberately not asked for at the start. Answering your first invitation asks for it: ",
|
||||
{ see: "read-invites" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"An account that is not Google granted nothing, so there is no list on its card. It shows the two servers it is using, the username it logs in with, and where its password is kept.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "acct-window",
|
||||
title: "How far back this device keeps",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"This device holds a window of the mailbox rather than all of it: the last 30 days unless you say otherwise, or 90 days, 180 days, a year, or everything. It is set per account in ",
|
||||
{ settings: "Settings" },
|
||||
", under Mail.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "storage",
|
||||
caption:
|
||||
"What is inside the window is on this device, and the rest of the mailbox is still at your provider.",
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"A thread you have done something to is kept whatever its age. A pile, a note, a snooze or any other decision is enough.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Widening the window starts a backfill and shows the same bar the first sync used. Narrowing it says how many threads will go before it does it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Only Everything",
|
||||
{ cap: "place-everything" },
|
||||
" says any of this out loud, in one line at the foot of its list. What is older is on the provider, and search offers to go and get it: ",
|
||||
{ see: "org-search" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "acct-privacy",
|
||||
title: "What leaves this device",
|
||||
blocks: [
|
||||
{
|
||||
p: ["Your mail, to and from your provider. Nothing else goes anywhere unless you ask for it."],
|
||||
},
|
||||
{
|
||||
figure: "privacy",
|
||||
caption: "What leaves is the mail itself, and a backup only if you ask for one.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A copy of the window you chose is on this computer, and so is everything this app invented: your piles, notes, renames, clips and sender rules, kept beside the mail rather than written into it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Backup is off until you choose a store in ",
|
||||
{ settings: "Settings" },
|
||||
", under Backup, and what it holds is ciphertext and file names and nothing else.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"No remote image loads until you ask for it, and nothing you send carries a tracker: ",
|
||||
{ see: "read-images" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
{ settings: "Settings" },
|
||||
", under Data, exports your mail as mbox and every decision as JSON. There is a question about leaving: ",
|
||||
{ see: "ask-leave" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// The guide's table of contents, assembled from the eight section files.
|
||||
//
|
||||
// The order is the order somebody meets the app: what it is, the gate in front of it, reading,
|
||||
// triage, writing, the things kept beside the mail, the accounts, and then the questions all of it
|
||||
// raises. How-to first and questions last, because a person with a question knows they have one and
|
||||
// will find the section named after it, while a person who is new does not know what to ask.
|
||||
|
||||
import { ACCOUNTS } from "./accounts";
|
||||
import { ORGANISING } from "./organising";
|
||||
import { QUESTIONS } from "./questions";
|
||||
import { READING } from "./reading";
|
||||
import { SCREENER } from "./screener";
|
||||
import { STARTED } from "./started";
|
||||
import { TRIAGE } from "./triage";
|
||||
import { WRITING } from "./writing";
|
||||
import { matches, textOf, type Article, type Section } from "./types";
|
||||
|
||||
export const SECTIONS: readonly Section[] = [
|
||||
STARTED,
|
||||
SCREENER,
|
||||
READING,
|
||||
TRIAGE,
|
||||
WRITING,
|
||||
ORGANISING,
|
||||
ACCOUNTS,
|
||||
QUESTIONS,
|
||||
];
|
||||
|
||||
interface Entry {
|
||||
article: Article;
|
||||
section: Section;
|
||||
/** Everything the article says, folded once at load rather than on every keystroke. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
const INDEX = new Map<string, Entry>();
|
||||
for (const section of SECTIONS) {
|
||||
for (const article of section.articles) {
|
||||
INDEX.set(article.id, { article, section, text: textOf(article) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The article the guide opens on, which is the first thing in the first section. */
|
||||
export const FIRST = SECTIONS[0].articles[0].id;
|
||||
|
||||
export function articleOf(id: string): Article | null {
|
||||
return INDEX.get(id)?.article ?? null;
|
||||
}
|
||||
|
||||
export function sectionOf(id: string): Section | null {
|
||||
return INDEX.get(id)?.section ?? null;
|
||||
}
|
||||
|
||||
/** What a cross-link prints: the target's own title, so a renamed article renames its links. */
|
||||
export function titleOf(id: string): string {
|
||||
return INDEX.get(id)?.article.title ?? id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail, narrowed to what answers the query. A section with nothing left in it is not shown,
|
||||
* and an empty query is every section whole.
|
||||
*/
|
||||
export function filterSections(query: string): Section[] {
|
||||
if (query.trim() === "") return [...SECTIONS];
|
||||
const out: Section[] = [];
|
||||
for (const section of SECTIONS) {
|
||||
const articles = section.articles.filter((article) =>
|
||||
matches(INDEX.get(article.id)?.text ?? article.title, query),
|
||||
);
|
||||
if (articles.length > 0) out.push({ ...section, articles });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every article in the rail as it currently stands, in order, which is what walks with the keys. */
|
||||
export function orderOf(sections: readonly Section[]): string[] {
|
||||
return sections.flatMap((section) => section.articles.map((article) => article.id));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
||||
// The guide's content, checked the way a link checker checks a manual.
|
||||
//
|
||||
// Nothing here renders anything. What can go wrong in a library of forty-odd articles is a link to
|
||||
// an article that was renamed, a picture nobody captured, and a title that says one thing while the
|
||||
// rail says another, and all three are readable straight off the data.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SECTIONS, articleOf, filterSections, titleOf } from "./content";
|
||||
import { PICTURES, matches, textOf, type Article, type Piece } from "./types";
|
||||
|
||||
const ARTICLES: Article[] = SECTIONS.flatMap((section) => [...section.articles]);
|
||||
|
||||
const piecesOf = (article: Article): Piece[] =>
|
||||
article.blocks.flatMap((block) =>
|
||||
"p" in block
|
||||
? block.p
|
||||
: "note" in block
|
||||
? block.note
|
||||
: "steps" in block
|
||||
? block.steps.flat()
|
||||
: [],
|
||||
);
|
||||
|
||||
describe("the shape of it", () => {
|
||||
it("is the how-to first and the questions last", () => {
|
||||
expect(SECTIONS.map((s) => s.title)).toEqual([
|
||||
"Getting started",
|
||||
"The Screener",
|
||||
"Reading",
|
||||
"Triage",
|
||||
"Writing",
|
||||
"Organising",
|
||||
"Accounts",
|
||||
"Questions",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every article an id of its own", () => {
|
||||
const ids = ARTICLES.map((a) => a.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("gives every article a title and something to say", () => {
|
||||
for (const article of ARTICLES) {
|
||||
expect(article.title.length).toBeGreaterThan(0);
|
||||
expect(article.blocks.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the links", () => {
|
||||
it("point at articles that exist", () => {
|
||||
for (const article of ARTICLES) {
|
||||
for (const piece of piecesOf(article)) {
|
||||
if (typeof piece === "object" && "see" in piece) {
|
||||
expect(articleOf(piece.see), `${article.id} links to ${piece.see}`).not.toBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("print the title the target carries, so renaming one renames its links", () => {
|
||||
expect(titleOf("screen-how")).toBe(articleOf("screen-how")?.title);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pictures", () => {
|
||||
const used = ARTICLES.flatMap((article) =>
|
||||
article.blocks.flatMap((block) => ("picture" in block ? [block.picture] : [])),
|
||||
);
|
||||
|
||||
it("are ones that were captured, and none is shown twice", () => {
|
||||
for (const name of used) expect(Object.keys(PICTURES)).toContain(name);
|
||||
expect(new Set(used).size).toBe(used.length);
|
||||
});
|
||||
|
||||
it("carry an alt and a caption", () => {
|
||||
for (const article of ARTICLES) {
|
||||
for (const block of article.blocks) {
|
||||
if (!("picture" in block)) continue;
|
||||
expect(block.alt.length).toBeGreaterThan(0);
|
||||
expect(block.caption.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The sizes are the guide's copy of a fact that lives in ten files somebody else captures, so
|
||||
// they are checked against the files rather than trusted. A recapture at a different crop fails
|
||||
// here, which is the only place it can fail before it is a picture drawn at the wrong size.
|
||||
it("are drawn at half the pixels they were captured at", () => {
|
||||
for (const [name, size] of Object.entries(PICTURES)) {
|
||||
const file = fileURLToPath(new URL(`../../../public/guide/${name}.png`, import.meta.url));
|
||||
const header = readFileSync(file);
|
||||
// The PNG header: an 8 byte signature, the IHDR length and type, then width and height.
|
||||
expect({ width: header.readUInt32BE(16) / 2, height: header.readUInt32BE(20) / 2 }).toEqual(
|
||||
size,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the figures", () => {
|
||||
const drawn = ARTICLES.flatMap((article) =>
|
||||
article.blocks.flatMap((block) => ("figure" in block ? [block] : [])),
|
||||
);
|
||||
|
||||
it("carry the sentence they are making", () => {
|
||||
for (const block of drawn) expect(block.caption.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// A wall of prose is what this guide was built to stop being, so the rule is mechanical: an
|
||||
// article says its piece in a few short paragraphs, and anything longer earns a picture, a
|
||||
// diagram, a list of steps or a table of keys to break it up.
|
||||
// Both of these gather every offender before asserting rather than expecting inside the loop.
|
||||
// An expect in a loop stops at the first one, which hid a second failing article behind the
|
||||
// first for as long as it took somebody to fix the first.
|
||||
it("break up anything long enough to need it", () => {
|
||||
const bare = ARTICLES.filter((article) => {
|
||||
const words = textOf(article).split(/\s+/).filter(Boolean).length;
|
||||
if (words < 120) return false;
|
||||
return !article.blocks.some(
|
||||
(block) => "figure" in block || "picture" in block || "keys" in block || "steps" in block,
|
||||
);
|
||||
}).map((article) => `${article.id} (${textOf(article).split(/\s+/).filter(Boolean).length} words)`);
|
||||
expect(bare, "long articles with nothing to look at").toEqual([]);
|
||||
});
|
||||
|
||||
it("keep a paragraph short enough to be read", () => {
|
||||
const long: string[] = [];
|
||||
for (const article of ARTICLES) {
|
||||
for (const block of article.blocks) {
|
||||
if (!("p" in block)) continue;
|
||||
const words = block.p
|
||||
.map((piece) => (typeof piece === "string" ? piece : ""))
|
||||
.join(" ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
if (words >= 75) long.push(`${article.id} (${words} words)`);
|
||||
}
|
||||
}
|
||||
expect(long, "paragraphs nobody will read").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the prose", () => {
|
||||
it("holds no dash the house does not write", () => {
|
||||
for (const article of ARTICLES) {
|
||||
const said = [
|
||||
textOf(article),
|
||||
...article.blocks.flatMap((block) => ("picture" in block ? [block.alt] : [])),
|
||||
].join(" ");
|
||||
expect(said, article.id).not.toMatch(/[–—]/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is what the filter reads, keycaps and cross-links aside", () => {
|
||||
const article = articleOf("read-images")!;
|
||||
expect(textOf(article)).toContain("tracker");
|
||||
expect(textOf(article)).toContain("Show images");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering", () => {
|
||||
it("is every section when nothing is typed", () => {
|
||||
expect(filterSections("").length).toBe(SECTIONS.length);
|
||||
expect(filterSections(" ").length).toBe(SECTIONS.length);
|
||||
});
|
||||
|
||||
it("finds an article by a word in its body rather than in its title", () => {
|
||||
const found = filterSections("tracker").flatMap((s) => s.articles.map((a) => a.id));
|
||||
expect(found).toContain("read-images");
|
||||
expect(found).not.toContain("write-drafts");
|
||||
});
|
||||
|
||||
it("answers nothing when nothing says it", () => {
|
||||
expect(filterSections("kryptonite")).toEqual([]);
|
||||
});
|
||||
|
||||
it("wants every word rather than any letter of them", () => {
|
||||
expect(matches("Snooze, and if no reply by", "snooze reply")).toBe(true);
|
||||
expect(matches("Snooze, and if no reply by", "snooze label")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/**
|
||||
* The things kept beside the mail rather than in it, and the two ways of finding something again.
|
||||
* Every article here ends up saying the same thing in a different way: none of it is written into
|
||||
* the mailbox, so none of it is lost when the mailbox changes hands.
|
||||
*/
|
||||
export const ORGANISING: Section = {
|
||||
id: "organising",
|
||||
title: "Organising",
|
||||
articles: [
|
||||
{
|
||||
id: "org-notes",
|
||||
title: "Notes",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Note",
|
||||
{ cap: "note" },
|
||||
" adds a private note to a thread. In the thread it is a block after the message that was latest when you wrote it, dated. In the list it is one line under the row.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: ["A note is kept against the thread rather than against a message in it."],
|
||||
},
|
||||
{
|
||||
note: ["Nothing is written to the mailbox, and nobody on the thread can see it."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-rename",
|
||||
title: "Rename a thread",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Click the subject in the reading pane, or run Rename from the palette",
|
||||
{ cap: "command-palette" },
|
||||
". The pane then shows the name you gave it, with what it was in small type beside it, and the list shows the new name.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Replies still carry the real subject, so the thread holds together at both ends.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: ["The rename is yours alone, and Undo", { cap: "undo" }, " takes it back."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-merge",
|
||||
title: "Merge threads",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Select the threads",
|
||||
{ cap: "select" },
|
||||
" and merge",
|
||||
{ cap: "merge" },
|
||||
": they read as one thread everywhere, named after the longest of them or after a name you type.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "merge",
|
||||
caption: "Two threads become one thread in every list, and the banner is the way back.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"It is for three people answering the same question in three separate threads.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A banner on the merged thread says where it came from and offers Unmerge. Replies and new messages in any of the threads underneath appear in the merged one.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-clips",
|
||||
title: "Clips",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Select text in any message and Save clip appears. Save the selection as a clip",
|
||||
{ cap: "save-clip" },
|
||||
" does it from the keyboard, anywhere.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Clips, in the palette, lists every passage you have kept with its sender, its thread and its date, and each one links back to where it was said.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-files",
|
||||
title: "All files",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"All files, in the palette, is every attachment on the device as a card: the name, the type, the size, who sent it and which thread it came in, newest first.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Filter it by type or by sender. Opening a card opens the thread the file arrived in.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"The small images a signature drags along are left out, so it is a list of the files somebody meant to send you.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-labels",
|
||||
title: "Labels and folders",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"The provider's labels and folders are places. They are listed under Labels in the palette",
|
||||
{ cap: "command-palette" },
|
||||
", one place each.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Label",
|
||||
{ cap: "label" },
|
||||
" applies or removes one on what is selected, and in a label's own list Move",
|
||||
{ cap: "move" },
|
||||
" moves a thread into another one.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: ["They are the provider's, and they roam with the mailbox."],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"The Inbox, the Feed and the Paper Trail are made of your decisions about senders, not of labels.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org-search",
|
||||
title: "Search, and what it reaches",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Search",
|
||||
{ cap: "search" },
|
||||
" runs over what is on this device: subjects, participants, snippets and the text of the messages.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "search-reach",
|
||||
caption:
|
||||
"This device answers first. The provider is asked at the foot of the results, and only then.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Results replace the list and the pane works as it always does. Escape gives you back the place you were in, where you were in it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The operators are from:, to:, subject:, has:attachment, filename:, in:, before:, after: and label:. The one that reads oddly is in:, which narrows to a single place.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Because the device holds a window of the mailbox rather than all of it, a result list is a partial answer and says so. Every one of them ends with a button that runs the provider's own search and appends what it finds. ",
|
||||
{ see: "acct-window" },
|
||||
" is how much of it is here to begin with.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Search reaches Spam, Trash and Screened out, and names the place on the row when it does. The message you most need to find is often the one something else decided you should not see.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/**
|
||||
* The questions this app raises by not working like the last one. The first sentence is the answer,
|
||||
* because somebody who is here is stuck or surprised and is owed the answer before the reasoning,
|
||||
* and every one of them names the article that has the rest.
|
||||
*/
|
||||
export const QUESTIONS: Section = {
|
||||
id: "questions",
|
||||
title: "Questions",
|
||||
articles: [
|
||||
{
|
||||
id: "ask-empty-inbox",
|
||||
title: "Why is my Inbox nearly empty?",
|
||||
blocks: [
|
||||
{
|
||||
p: ["Three other places have taken what used to land in one."],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Newsletters go to the Feed, receipts and confirmations to the Paper Trail, and the first message from anyone you have not decided about waits in the Screener. Nothing was deleted, and Everything",
|
||||
{ cap: "place-everything" },
|
||||
" is the one list that holds all of it: ",
|
||||
{ see: "start-places" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-newsletter",
|
||||
title: "Where did that newsletter go?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"The Feed",
|
||||
{ cap: "place-feed" },
|
||||
", almost certainly, which is where a sender carrying an unsubscribe header is routed.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"If it is the first thing that sender has ever sent you, it is in the Screener",
|
||||
{ cap: "place-screener" },
|
||||
" instead. Search",
|
||||
{ cap: "search" },
|
||||
" reaches every place either way: ",
|
||||
{ see: "org-search" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-nothing-sent",
|
||||
title: "Does a sender find out that I screened them out?",
|
||||
blocks: [
|
||||
{
|
||||
p: ["No. Nothing is ever sent."],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Their mail keeps arriving as it always did and is routed to Screened out, where you can read it. ",
|
||||
{ see: "screen-back" },
|
||||
" is how it is undone.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-labels",
|
||||
title: "Can I still get to my Gmail labels and folders?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Yes. Every label is a place, listed under Labels in the palette",
|
||||
{ cap: "command-palette" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Label",
|
||||
{ cap: "label" },
|
||||
" applies or removes one. They are the provider's and they roam with the mailbox, which is why they are not what the Inbox, the Feed and the Paper Trail are made of: ",
|
||||
{ see: "org-labels" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-last-year",
|
||||
title: "Why can I not find mail from last year?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"This device keeps a window of the mailbox, the last 30 days unless you asked for more.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The rest is still with your provider, and every list of search results ends with a button that goes and gets it. To keep more of it here, widen the window: ",
|
||||
{ see: "acct-window" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-empty-trash",
|
||||
title: "Is there an Empty Trash button?",
|
||||
blocks: [
|
||||
{
|
||||
p: ["No. Gmail empties Trash after 30 days, and the foot of the list says so."],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Deleting for good through Gmail's API needs a permission that amounts to total access to the mailbox, and asking every account for that to destroy things thirty days early is a bad trade. The key that trashed a thread is the key that puts it back: ",
|
||||
{ see: "triage-archive" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-ai",
|
||||
title: "Does this app read my mail with AI?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"No. There is none in it, and nothing you have is sent anywhere to be summarised, drafted or classified.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Where a message goes is decided by its headers and by the rules you set, which is why every Screener card says in one line which rule fired: ",
|
||||
{ see: "screen-how" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-where-kept",
|
||||
title: "Where is my mail kept, and what leaves this device?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"On this computer: a copy of the window you chose, with your piles, notes, renames, clips and sender rules beside it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"What leaves is your mail, to and from your provider, and a backup if you turn one on, which holds ciphertext and file names and nothing else. ",
|
||||
{ see: "acct-privacy" },
|
||||
" has the rest.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-notify",
|
||||
title: "Why did nothing notify me?",
|
||||
blocks: [
|
||||
{
|
||||
p: ["Because notifications are off everywhere until you turn one on."],
|
||||
},
|
||||
{
|
||||
figure: "notify",
|
||||
caption: "A thread, a person or a place can be turned on, and all of them start off.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Turn one on for a thread",
|
||||
{ cap: "notify" },
|
||||
", for a person from their contact card",
|
||||
{ cap: "contact-card" },
|
||||
", or for a whole place in ",
|
||||
{ settings: "Settings" },
|
||||
", where one switch covers the machine. Only mail that arrives while the app is running is announced, so a first sync or a week away says nothing about the backlog.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"The dock badge is the exception and it is on. It is not a notification: it counts the Inbox threads waiting for you.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-archived",
|
||||
title: "How do I get back something I archived?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"It is in Everything",
|
||||
{ cap: "place-everything" },
|
||||
", which holds every thread on the device.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Archiving takes a thread out of the Inbox and does nothing else to it, and a new message in an archived thread brings it back on its own. If it was the last thing you did, Undo",
|
||||
{ cap: "undo" },
|
||||
" takes it back: ",
|
||||
{ see: "triage-archive" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ask-leave",
|
||||
title: "What happens if I stop using this app?",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Nothing is done to your mailbox that your provider cannot already see. Archiving, trashing, spam, labels and sends are ordinary mailbox changes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Everything this app invented is kept beside the mail and never written into it, and ",
|
||||
{ settings: "Settings" },
|
||||
", under Data, exports your mail as mbox with every decision as JSON alongside. There is more in ",
|
||||
{ see: "acct-privacy" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/** Reading: the pane, the Feed, and the four things a message can carry that are not prose. */
|
||||
export const READING: Section = {
|
||||
id: "reading",
|
||||
title: "Reading",
|
||||
articles: [
|
||||
{
|
||||
id: "read-thread",
|
||||
title: "Open a thread and move through it",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"The list is on the left and the thread is in the pane beside it, so triage happens without leaving the list. Open",
|
||||
{ cap: "open-selection" },
|
||||
" opens whatever is focused.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "thread",
|
||||
caption:
|
||||
"The latest message is open and the ones before it are collapsed to a line each.",
|
||||
},
|
||||
{
|
||||
keys: [
|
||||
"select-next",
|
||||
"select-prev",
|
||||
"open-selection",
|
||||
"message-next",
|
||||
"message-prev",
|
||||
"message-toggle",
|
||||
"message-expand-all",
|
||||
"toggle-pane",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Quoted text is behind a pill, so a long thread reads as the conversation rather than as the same paragraph six times.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Opening a thread marks it seen. Mark seen or unseen",
|
||||
{ cap: "toggle-seen" },
|
||||
" puts that back.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Weight is the only thing that says a thread is new. There is no unread count on a place, on a group or on a row.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-together",
|
||||
title: "Read several threads together",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Select the threads and Open",
|
||||
{ cap: "open-selection" },
|
||||
" shows them one after another in the pane, each under its own heading. It is how a morning of five short threads is read once rather than five times.",
|
||||
],
|
||||
},
|
||||
{
|
||||
keys: [
|
||||
"select",
|
||||
"select-extend-down",
|
||||
"select-extend-up",
|
||||
"select-all",
|
||||
"open-selection",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Escape clears the selection. While there is one, the piles at the foot of the list give way to a bar of the same verbs, and what they do is ",
|
||||
{ see: "triage-several" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-feed",
|
||||
title: "The Feed reads differently",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"The Feed",
|
||||
{ cap: "place-feed" },
|
||||
" takes the whole window rather than a list beside a pane, because every card is already open. Newest first, no read state, no counts, and nothing telling you how far behind you are.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "feed",
|
||||
alt: "The Feed, one column of cards with each newsletter drawn open",
|
||||
caption: "One column of cards, each already open, under the line that counts the trackers.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Next",
|
||||
{ cap: "select-next" },
|
||||
" and previous",
|
||||
{ cap: "select-prev" },
|
||||
" move between cards. A card longer than a screen fades out with Read more, and Open",
|
||||
{ cap: "open-selection" },
|
||||
" expands it in place and closes it again.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: ["A hairline reading you left off here marks where you stopped last time."],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The foot of a card carries Save clip, Unsubscribe and Move, and the top of the column says how many trackers were stripped today.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-images",
|
||||
title: "Images and trackers",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Remote images do not load until you ask. A banner at the top of the message says how many trackers were stripped and names the vendor, and Show images loads them for that message.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "trackers",
|
||||
caption: "The images are held and the trackers are counted before you see any of it.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Loading a remote image tells the server that hosts it that you opened the message, from your IP address, at that moment. That is the whole reason the images wait.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The contact card",
|
||||
{ cap: "contact-card" },
|
||||
" allows a sender's images always. ",
|
||||
{ settings: "Settings" },
|
||||
", under Privacy, sets the rule for everything: never, ask per message, or always. The senders you have allowed are listed there too.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Message bodies render with scripts, forms and external styles removed. A link shows its real destination on hover and opens with the tracking parameters taken off.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: ["Nothing you send carries a tracker, and nothing reports when it was opened."],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-attachments",
|
||||
title: "Attachments",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Attachments are chips at the foot of the message that carried them, each with a mark for its type. Pressing one opens the file with whatever owns that type on this machine.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"They are fetched when you open the thread rather than during sync, and kept after that, so a mailbox full of attachments does not become a disk full of them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
{ see: "org-files" },
|
||||
", in the palette, is every one of them on the device in a single grid, filtered by type and by sender.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-invites",
|
||||
title: "Calendar invitations",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"An invitation renders as a card in the thread: the date in a box, the title, the time and place, the organiser, and the three answers. Open in Margin Calendar is beside them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
keys: ["invite-accept", "invite-maybe", "invite-decline"],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Answering writes to the calendar the invitation was sent to. When the event is not there yet, it is put there first.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Calendar is not one of the permissions asked for when an account is added, so the first invitation you answer asks for it and runs the consent page again. Until then the card is read-only, and ",
|
||||
{ see: "acct-permissions" },
|
||||
" says what else an account granted.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/**
|
||||
* The gate, and the three things anybody asks about it: how a suggestion is made, how a decision is
|
||||
* changed afterwards, and what a No actually does.
|
||||
*/
|
||||
export const SCREENER: Section = {
|
||||
id: "screener",
|
||||
title: "The Screener",
|
||||
articles: [
|
||||
{
|
||||
id: "screen-how",
|
||||
title: "How the Screener works",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"A first message from a sender you have no decision about is held. It is in no box, and the Inbox shows a pill saying how many senders are waiting.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The Screener",
|
||||
{ cap: "place-screener" },
|
||||
" is one card per sender: who they are, what they sent, and the box the app suggests with the reason that made it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "screener-card",
|
||||
caption:
|
||||
"What you are deciding is where that sender goes, not what to do with one message.",
|
||||
},
|
||||
{ keys: ["screen-yes", "screen-elsewhere", "screen-no", "screen-reply"] },
|
||||
{
|
||||
p: [
|
||||
"Yes and Elsewhere set the rule for that address, and Elsewhere can set it for everyone at the domain instead. No routes the sender's mail to Screened out from then on.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The suggestion is a rule rather than a guess. Written by a person, so Inbox. Carries an unsubscribe header, so Feed. Sent by a service on somebody's behalf, or a receipt, so Paper Trail, even when it carries an unsubscribe footer as well. The card says which rule fired.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Only senders whose first message arrives after the account was added wait here. A reply to a thread you are already in is never held.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: ["Nothing is sent to the sender, whichever you press."],
|
||||
},
|
||||
{
|
||||
picture: "screener",
|
||||
alt: "The Screener, with a card per sender and the three choices on the right of each",
|
||||
caption: "Three senders waiting, each with its suggested box and the reason for it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "screen-where",
|
||||
title: "Say where a sender goes, and change it later",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"One rule per sender, changed in one place: the contact card",
|
||||
{ cap: "contact-card" },
|
||||
". Delivers to is the row that says where their mail goes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "contact-card",
|
||||
alt: "The contact card hanging from a sender's name, with Delivers to, Notify, a note and recent threads",
|
||||
caption: "Delivers to sets the box, and the switch under it sets the whole domain.",
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Changing it moves the threads that are already here, not only the ones still to come.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The card opens on the thread you are looking at, and a click on any name or avatar opens it too. Move",
|
||||
{ cap: "move" },
|
||||
" does the same from the list without opening anything, and Contacts, in the palette",
|
||||
{ cap: "command-palette" },
|
||||
", lists everyone you have decided about with the same control on each row.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A rule is keyed on the address, or on the domain when you chose everyone at that domain. An address rule beats a domain rule. The shared domains everybody's mail comes from, gmail.com and the like, cannot carry a domain rule at all.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "screen-back",
|
||||
title: "Bring back somebody you screened out",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Set Delivers to on their contact card. Whatever they sent that is still on the device comes back with them, into the box you choose.",
|
||||
],
|
||||
},
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
"Open Screened out from the palette",
|
||||
{ cap: "command-palette" },
|
||||
", under Other.",
|
||||
],
|
||||
["Open a thread from the sender you want back."],
|
||||
["Open their contact card", { cap: "contact-card" }, " and set Delivers to."],
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Nothing was sent when you screened them out, and nothing is sent when you let them back in. There is a question about exactly that: ",
|
||||
{ see: "ask-nothing-sent" },
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Screened-out mail sits there for as long as this account keeps mail on the device, and falls off with everything else of its age. ",
|
||||
{ see: "acct-window" },
|
||||
" is the setting that decides how long that is.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "screen-clear",
|
||||
title: "Clear the whole queue",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Clear all screens out every sender waiting, after one confirmation that says how many. It sits at the top right of the Screener, above the cards.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"It is the answer to a queue that has run away from you rather than a decision about the people in it. Nothing is sent, their mail goes to Screened out from then on, and any of them can be let back in: ",
|
||||
{ see: "screen-back" },
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [{ place: "screener", label: "Open the Screener" }, " to see what is waiting."],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/**
|
||||
* The first section, and the one somebody reads once. Everything in it is about the shape of the
|
||||
* app rather than about a verb: what is different, what the window holds, where mail lands, what
|
||||
* the first hour is, and why every button prints a letter.
|
||||
*/
|
||||
export const STARTED: Section = {
|
||||
id: "started",
|
||||
title: "Getting started",
|
||||
articles: [
|
||||
{
|
||||
id: "start-different",
|
||||
title: "What is different here",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Your mail is not one list. People, newsletters and receipts go to three separate places, and somebody writing to you for the first time waits at a gate before they reach any of them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "boxes",
|
||||
caption:
|
||||
"Mail from people, kept apart from newsletters and receipts by a decision you made.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Nothing guesses on your behalf. You say where a sender goes, once, and that decision covers everything they send from then on. ",
|
||||
{ see: "start-places" },
|
||||
" is the places there are, and ",
|
||||
{ see: "screen-how" },
|
||||
" is the gate.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Reply later",
|
||||
{ cap: "reply-later" },
|
||||
" and Set aside",
|
||||
{ cap: "set-aside" },
|
||||
" are piles at the foot of the list rather than flags on a row. A thread you pile leaves the list, and the same key brings it back.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: ["Every verb is one key, and the button that runs it prints the key."],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"There is no counter, no streak and no assistant. The one number anywhere is the dock badge, and it counts unseen Inbox threads.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-window",
|
||||
title: "The shape of the window",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"One header, then the stage. The header carries the account on the left, the three boxes in the middle with their number keys on them, and search, places and Write on the right.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "window",
|
||||
caption: "There is nothing else to learn: no sidebar, no folder tree, no toolbar.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The list column has the place's name at its head, and in the Inbox the pill saying how many senders are waiting. The piles sit under the list and are always in view.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The reading pane can be hidden",
|
||||
{ cap: "toggle-pane" },
|
||||
". The list then takes the width and a thread opens in place, with Escape going back to the list.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Everything that is not one of the three boxes is in the palette",
|
||||
{ cap: "command-palette" },
|
||||
", which is the only menu in the app and the way every setting is reached.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "inbox",
|
||||
alt: "The Inbox: the list on the left with the two piles under it, and a thread open in the reading pane",
|
||||
caption: "Every verb in the bar over the message prints the key it answers to.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-places",
|
||||
title: "Where your mail goes",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Every sender has exactly one destination. Three of them are boxes you read, and the fourth is Screened out, which is where the mail you never want to see goes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "routing",
|
||||
caption:
|
||||
"A first message waits at the gate. After that, everything from that sender goes straight to its box.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Inbox",
|
||||
{ cap: "place-inbox" },
|
||||
" is people, and the few services you want to hear from as they arrive. It is one list in time order, and a reply pulls a thread back up it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Feed",
|
||||
{ cap: "place-feed" },
|
||||
" is newsletters and long reads. Every item is already open, newest first, with no read state and no count.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Paper Trail",
|
||||
{ cap: "place-paper-trail" },
|
||||
" is receipts, confirmations and the mail a machine sent you: the things you file and search for later.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Screener",
|
||||
{ cap: "place-screener" },
|
||||
" is the gate in front of the three. It holds the first message from anyone you have not decided about. ",
|
||||
{ see: "screen-how" },
|
||||
" is the whole of it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Everything",
|
||||
{ cap: "place-everything" },
|
||||
" is the one list that holds all of it at once, archived, spam and screened out included. Nothing in this app is anywhere you cannot get to.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-day",
|
||||
title: "Your first day",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Empty the Screener, put right anything the Feed and the Paper Trail have in the wrong place, and pile what you cannot answer now. That is the first hour.",
|
||||
],
|
||||
},
|
||||
{
|
||||
steps: [
|
||||
[
|
||||
{ place: "screener", label: "Open the Screener" },
|
||||
" and empty it. Every card carries a suggestion and the reason for it. What each answer does is in ",
|
||||
{ see: "screen-how" },
|
||||
".",
|
||||
],
|
||||
[
|
||||
"Look through the Feed",
|
||||
{ cap: "place-feed" },
|
||||
" and the Paper Trail",
|
||||
{ cap: "place-paper-trail" },
|
||||
". Anything in the wrong one is one change on the sender's contact card",
|
||||
{ cap: "contact-card" },
|
||||
".",
|
||||
],
|
||||
[
|
||||
"Pile rather than file. Reply later",
|
||||
{ cap: "reply-later" },
|
||||
" is what you owe an answer to, and Set aside",
|
||||
{ cap: "set-aside" },
|
||||
" is what you need to hand.",
|
||||
],
|
||||
[
|
||||
"When you cannot remember a key, the palette",
|
||||
{ cap: "command-palette" },
|
||||
" lists every place, every command and every setting, with the key beside it.",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Everyone the account already knew was screened in when it was added, so what waits in the Screener is somebody genuinely new.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "palette",
|
||||
alt: "The command palette with two letters typed in it, its rows grouped into Places, Other and Actions",
|
||||
caption: "Two letters narrow every group at once, and every row prints its key.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "start-keys",
|
||||
title: "Every key is printed",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"One unmodified key per verb. Nothing is chorded, nothing is modal, and a key that would act on nothing does nothing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "keyboard",
|
||||
caption: "The verbs you use hourly, on the keys they answer to.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Every button carries the key its verb answers to, which is how the mouse teaches the keyboard. The palette",
|
||||
{ cap: "command-palette" },
|
||||
" prints the same keys beside the same commands, and the whole table is behind",
|
||||
{ cap: "shortcuts" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Where Gmail and Superhuman agree on a letter, this app uses theirs. Where HEY has a verb they do not, it uses HEY's.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The keymap is a file in the app's data directory. ",
|
||||
{ settings: "Settings" },
|
||||
", under Keyboard, opens it and resets it, and the sheet is generated from that file, so a key you remap is the key the buttons print.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "shortcuts",
|
||||
alt: "The keyboard shortcuts sheet, with the verbs grouped and a key printed beside each",
|
||||
caption: "The sheet is generated from the keymap, so it cannot drift from what the keys do.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/** Triage: what the single keys do to a thread, and how each of them is undone. */
|
||||
export const TRIAGE: Section = {
|
||||
id: "triage",
|
||||
title: "Triage",
|
||||
articles: [
|
||||
{
|
||||
id: "triage-archive",
|
||||
title: "Archive, trash and spam",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Archive takes a thread out of the Inbox. It is not deleted and it is not hidden: it lives in Everything",
|
||||
{ cap: "place-everything" },
|
||||
" with the rest of the mailbox.",
|
||||
],
|
||||
},
|
||||
{
|
||||
keys: ["archive", "trash", "spam", "undo"],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A new message in an archived thread brings it back to the top of the Inbox on its own.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Trash and spam are each the same key twice. Pressed again in Trash, a thread comes back, and it lands where it was rather than in the Inbox.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Pressed again in Spam, the spam mark comes off and the thread is routed like any other: to its sender's box, or to the Screener if you never decided about them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"There are two ways back and then a deadline. The toast that is still up, and Undo",
|
||||
{ cap: "undo" },
|
||||
" while it is. The place and the same verb, a week later. After thirty days Gmail empties its own trash and the message goes with it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Trash and Spam are in the palette under Other. There is no Empty button, and there is a question about why: ",
|
||||
{ see: "ask-empty-trash" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "triage-piles",
|
||||
title: "Reply later and Set aside",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Two stacks of cards sit at the foot of the list, always in view. Reply later",
|
||||
{ cap: "reply-later" },
|
||||
" is what you owe an answer to. Set aside",
|
||||
{ cap: "set-aside" },
|
||||
" is what you need to hand: a ticket, an itinerary, a code.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "piles",
|
||||
caption: "A pile takes a thread out of its list, and the same key puts it back.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Reply later",
|
||||
{ cap: "place-reply-later" },
|
||||
" and Set aside",
|
||||
{ cap: "place-set-aside" },
|
||||
" are places as well as piles, with the pane beside them. Sending a reply on a Reply later thread clears it from the pile, with an undo.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "piles",
|
||||
alt: "The two piles at the foot of the list, Reply later on the left and Set aside on the right",
|
||||
caption: "Each stack carries its label, its key, and the thread on top of it.",
|
||||
},
|
||||
{
|
||||
p: [{ see: "triage-focus" }, " is the other half of the arrangement."],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Nothing nags. A thread can sit in Set aside for a year and the app will never mention it.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "triage-focus",
|
||||
title: "Focus & Reply",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Focus & Reply lines up every thread in the Reply later pile on one page, each with its latest message on the left and a reply box on the right.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "focus",
|
||||
caption: "One page, one item per thread you owe an answer to.",
|
||||
},
|
||||
{
|
||||
keys: ["focus-reply", "focus-next", "focus-prev", "send"],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Sending collapses the item to a Sent to line and moves on. Escape leaves the page.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Items you skip stay in the pile. It is an hour spent answering rather than a queue you have to finish.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "triage-snooze",
|
||||
title: "Snooze, and if no reply by",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Snooze",
|
||||
{ cap: "snooze" },
|
||||
" takes a thread away until later today, tomorrow, the weekend, next week, or a time you pick. It leaves its list and waits in Snoozed",
|
||||
{ cap: "place-snoozed" },
|
||||
" with the time it is due back.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "snooze",
|
||||
caption: "The choices are points on one line of time, from later today to next week.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"If no reply by is the last choice in the same picker. That thread comes back only if nobody but you has written to it since; a reply cancels the reminder and lands as normal.",
|
||||
],
|
||||
},
|
||||
{
|
||||
picture: "snooze",
|
||||
alt: "The snooze picker open on a thread, with its six choices and the key beside each",
|
||||
caption: "Six choices, each with its key, and a date picker behind the last two.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"What comes back arrives under Back, at the top of the place it left, and stays there until it is opened.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The times behind later today, tomorrow, the weekend and next week are yours to set, in ",
|
||||
{ settings: "Settings" },
|
||||
" under Piles and snooze.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"Nothing runs in the background. Whichever of your devices next opens the app works out what is due, so a thread can come back late, and one that does says it was due yesterday.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "triage-ignore",
|
||||
title: "Ignore a thread",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Ignore",
|
||||
{ cap: "ignore" },
|
||||
" is for the thread that will not end. It stops that thread reading as new, counting on the badge, or notifying you.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"New messages still arrive and still append, and the thread still rises with them, because the list is in time order.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A banner on the thread says you are ignoring it and offers Stop ignoring. The same key stops it too.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "triage-several",
|
||||
title: "Act on several at once",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"A verb acts on the whole selection when there is one, and the two piles give way to a bar carrying the verbs a selection can take.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "selection",
|
||||
caption: "The piles' footprint, filled with the same verbs and the same keys.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Open",
|
||||
{ cap: "open-selection" },
|
||||
" reads them one after another, and Merge",
|
||||
{ cap: "merge" },
|
||||
" is the selection's own verb: it wants two threads or more. The keys that build a selection are in ",
|
||||
{ see: "read-together" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: ["Every bulk action is one undo", { cap: "undo" }, ", not one per thread."],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
// What an article is made of.
|
||||
//
|
||||
// The prose is data rather than JSX, which is the one thing this screen needs that the tour does
|
||||
// not: the filter searches what an article says, and a body that only exists as rendered elements
|
||||
// can only be searched by rendering it. A block model keeps the words in one place, the markup in
|
||||
// `Article.tsx`, and the search over the words themselves.
|
||||
//
|
||||
// It also settles the keycap rule mechanically. There is no way to type a letter into a sentence
|
||||
// where a cap belongs, because a cap is a command id and the keymap answers it.
|
||||
|
||||
import { labelFor, type CommandId } from "../../keys/bindings";
|
||||
import type { Place } from "../../ipc";
|
||||
|
||||
/**
|
||||
* Every picture the guide may show, with the size it is drawn at, and the whole list of them.
|
||||
*
|
||||
* They are captured from the real app by another package and land in `public/guide`, so a name here
|
||||
* that nobody captures is a broken picture and a capture nobody names is a file for nothing.
|
||||
*
|
||||
* The size is half the file's pixels, because every capture is at twice the size. That is the size
|
||||
* the thing in the picture actually was, and drawing it at anything less is a screenshot of an app
|
||||
* whose type is too small to read: at two thirds, the app's own body text lands at 10px. The
|
||||
* numbers ride here rather than in the stylesheet so they can go on the `img` itself, which is what
|
||||
* keeps the page from jumping as a picture arrives, and `guide.test.ts` reads the files to check
|
||||
* that they are still true.
|
||||
*/
|
||||
export const PICTURES = {
|
||||
inbox: { width: 1440, height: 900 },
|
||||
screener: { width: 1440, height: 854 },
|
||||
feed: { width: 1440, height: 854 },
|
||||
piles: { width: 419, height: 115 },
|
||||
palette: { width: 620, height: 479 },
|
||||
compose: { width: 600, height: 421 },
|
||||
"contact-card": { width: 320, height: 447 },
|
||||
snooze: { width: 264, height: 188 },
|
||||
shortcuts: { width: 620, height: 820 },
|
||||
settings: { width: 1440, height: 854 },
|
||||
} as const;
|
||||
|
||||
export type PictureName = keyof typeof PICTURES;
|
||||
|
||||
/**
|
||||
* A piece of a sentence. Prose is a string, and everything else is a thing only the app can fill
|
||||
* in: the key a verb answers to today, another article's title, a way to the place being described.
|
||||
*
|
||||
* A cap always follows the name of the verb it belongs to, and the sentence has to read with the
|
||||
* cap taken out, because on a phone it is taken out: `<Key>` hides itself there.
|
||||
*/
|
||||
export type Piece =
|
||||
| string
|
||||
| { cap: CommandId }
|
||||
/** Another article, printed with that article's own title. */
|
||||
| { see: string }
|
||||
| { place: Place; label: string }
|
||||
| { settings: string };
|
||||
|
||||
/**
|
||||
* Every diagram the guide may draw, and what each one is about.
|
||||
*
|
||||
* A picture is the app photographed; a figure is an idea drawn. Where mail goes, what a pile does,
|
||||
* how long a send is held: none of those is a thing you can point a camera at, and all of them are
|
||||
* the thing somebody actually came to understand. They are built from the same tokens as the app,
|
||||
* in `Figures.tsx`, so they cannot drift from what they describe the way an exported drawing would.
|
||||
*/
|
||||
export type FigureId =
|
||||
/** A message arriving, the gate, and the four places it can end up. */
|
||||
| "routing"
|
||||
/** Inbox, Feed and Paper Trail beside each other, with what each one holds. */
|
||||
| "boxes"
|
||||
/** The shape of the window: header, list column, reading pane, the two piles at the foot. */
|
||||
| "window"
|
||||
/** The verbs of triage as the keys they answer to. */
|
||||
| "keyboard"
|
||||
/** One Screener card, labelled: who, what, the reason, and the three answers. */
|
||||
| "screener-card"
|
||||
/** A thread in the pane: the latest message open, the older ones collapsed to a line. */
|
||||
| "thread"
|
||||
/** A message with its remote images held back and its trackers counted. */
|
||||
| "trackers"
|
||||
/** A thread leaving the list for a pile, and the same key bringing it back. */
|
||||
| "piles"
|
||||
/** The snooze choices along a line of time. */
|
||||
| "snooze"
|
||||
/** Rows selected, and the piles replaced by the bar of verbs. */
|
||||
| "selection"
|
||||
/** Focus and Reply: every thread you owe, each with its box. */
|
||||
| "focus"
|
||||
/** A send held for its ten seconds, and what the toast offers while it waits. */
|
||||
| "undo"
|
||||
/** Two threads becoming one, and the banner that says so. */
|
||||
| "merge"
|
||||
/** Where a search looks: this device first, the provider on request. */
|
||||
| "search-reach"
|
||||
/** The window of mail this device holds, and the rest of the mailbox behind it. */
|
||||
| "storage"
|
||||
/** Several mailboxes in one window, each with its own everything. */
|
||||
| "accounts"
|
||||
/** The three switches a notification has to pass, all of them off to begin with. */
|
||||
| "notify"
|
||||
/** What stays on the device and what leaves it. */
|
||||
| "privacy";
|
||||
|
||||
export type Block =
|
||||
| { p: Piece[] }
|
||||
| { steps: Piece[][] }
|
||||
| { picture: PictureName; alt: string; caption: string }
|
||||
/** A drawn idea, with the sentence it is making underneath it. */
|
||||
| { figure: FigureId; caption: string }
|
||||
/**
|
||||
* The verbs of a thing as a table of the key and what it does, generated from the binding table.
|
||||
* A paragraph that lists six keys is a paragraph nobody reads; the same six as rows are read at a
|
||||
* glance, and they cannot go stale because none of the words in them are written here.
|
||||
*/
|
||||
| { keys: readonly CommandId[] }
|
||||
/** One line set apart: the thing people get wrong, or the promise worth saying twice. */
|
||||
| { note: Piece[] };
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
blocks: readonly Block[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
articles: readonly Article[];
|
||||
}
|
||||
|
||||
const wordsOf = (pieces: readonly Piece[]): string[] =>
|
||||
pieces.map((piece) => {
|
||||
if (typeof piece === "string") return piece;
|
||||
if ("place" in piece) return piece.label;
|
||||
if ("settings" in piece) return piece.settings;
|
||||
return "";
|
||||
});
|
||||
|
||||
/**
|
||||
* Everything an article says in words, which is what the filter reads.
|
||||
*
|
||||
* A keycap is not in it and neither is a cross-link, because both print something this article did
|
||||
* not write: a search for `e` that turned up every article with an archive key in it would be a
|
||||
* search over the keymap wearing a search over the prose.
|
||||
*/
|
||||
export function textOf(article: Article): string {
|
||||
const parts: string[] = [article.title];
|
||||
for (const block of article.blocks) {
|
||||
if ("p" in block) parts.push(...wordsOf(block.p));
|
||||
else if ("note" in block) parts.push(...wordsOf(block.note));
|
||||
else if ("steps" in block) for (const step of block.steps) parts.push(...wordsOf(step));
|
||||
// A key table's words are whole verbs printed on the page, which is not the case a cap makes:
|
||||
// somebody searching for "unsubscribe" should find the article that lists it in a row.
|
||||
else if ("keys" in block) parts.push(...block.keys.map(labelFor));
|
||||
else parts.push(block.caption);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a body answers a query: every word of it is somewhere in the text.
|
||||
*
|
||||
* Not the palette's subsequence match, which is right for a row of three words and wrong for a
|
||||
* page of them: any three letters are a subsequence of any paragraph, so a filter over bodies
|
||||
* built that way narrows nothing.
|
||||
*/
|
||||
export function matches(text: string, query: string): boolean {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return true;
|
||||
const hay = text.toLowerCase();
|
||||
return words.every((word) => hay.includes(word));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Section } from "./types";
|
||||
|
||||
/** Writing: the card, the box in the thread, and the four things the send footer carries. */
|
||||
export const WRITING: Section = {
|
||||
id: "writing",
|
||||
title: "Writing",
|
||||
articles: [
|
||||
{
|
||||
id: "write-compose",
|
||||
title: "Write, reply, reply all, forward",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"New message opens a card over the list, bottom right, with the list still usable behind it. A reply does not use the card: it opens a box under the last message in the thread, with the recipients as chips, where the mail you are answering already is.",
|
||||
],
|
||||
},
|
||||
{ keys: ["compose", "reply", "reply-all", "forward", "compose-expand"] },
|
||||
{
|
||||
picture: "compose",
|
||||
alt: "The compose card floating over the Inbox, with the From, To and Subject fields and the send footer",
|
||||
caption: "From, To, Subject, the body, and a footer with the send key and the undo delay.",
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The editor does paragraphs, bold, italic, links, lists, quotes and code. No colours and no fonts, because mail that arrives looking like the machine it was written on is mail that arrives looking wrong.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Whether the reply key means reply or reply all is yours to set, in ",
|
||||
{ settings: "Settings" },
|
||||
" under Writing.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "write-undo",
|
||||
title: "Undo a send",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Every send is held for ten seconds before it goes. A toast at the foot of the window says who it went to and offers Undo, and taking it back reopens the draft where it was.",
|
||||
],
|
||||
},
|
||||
{
|
||||
figure: "undo",
|
||||
caption: "Ten seconds between the key and the message leaving, and the toast is the way back.",
|
||||
},
|
||||
{ keys: ["send", "send-now", "undo"] },
|
||||
{
|
||||
note: [
|
||||
"Nothing has left this machine while the toast is up. Undo puts the draft back rather than chasing a message that has already gone.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The delay is five, ten, twenty or thirty seconds, in ",
|
||||
{ settings: "Settings" },
|
||||
" under Writing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A send that fails, or one made offline, waits and retries. The thread says it is waiting to send until it goes, so a message never quietly does not exist.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "write-attachments",
|
||||
title: "Attachments, and the size limit",
|
||||
blocks: [
|
||||
{
|
||||
p: ["Drag a file onto the message, paste it, or Attach", { cap: "attach" }, "."],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The provider sets a limit on the whole encoded message, which for Gmail is 35 MB.",
|
||||
],
|
||||
},
|
||||
{
|
||||
note: [
|
||||
"A file that would push a message past the limit is refused as you attach it, rather than after you have written the message and pressed send.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "write-remind",
|
||||
title: "Remind me if no reply",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Remind me if no reply",
|
||||
{ cap: "remind-if-no-reply" },
|
||||
" is a toggle in the send footer with a date on it. If nobody but you has written by that date, the thread comes back to the top of the place it lives in, under Back.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"A reply cancels it, and the reply lands as normal. The toggle applies to the thread once the send has gone.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"It is the last choice in the snooze picker under another name, and it comes back the same way: ",
|
||||
{ see: "triage-snooze" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "write-intro",
|
||||
title: "Instant intro",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"Instant intro",
|
||||
{ cap: "instant-intro" },
|
||||
" in a reply moves the introducer to Bcc and puts a thank-you line at the top. Pressing it again reverts both.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"It is for the mail that introduces you to somebody else, where the first thing you write is a thank-you to the introducer and a note that they can drop off the thread.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The line is a template, and it is yours to write once, in ",
|
||||
{ settings: "Settings" },
|
||||
" under Writing.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "write-drafts",
|
||||
title: "Drafts and signatures",
|
||||
blocks: [
|
||||
{
|
||||
p: [
|
||||
"A draft saves to this device as you type and to the provider every few seconds, so it is in your mailbox's drafts as well and it follows you to another machine.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"Escape leaves the editor and keeps the draft. Discard",
|
||||
{ cap: "discard-draft" },
|
||||
" throws it away.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The signature is the one the provider holds for the address you are sending from. It is editable in ",
|
||||
{ settings: "Settings" },
|
||||
", on the account card under Accounts and again under Writing, which is where somebody writing a signature looks for it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
p: [
|
||||
"The From field picks the account a message goes through, and an alias the provider has verified can be chosen there too: ",
|
||||
{ see: "acct-switch" },
|
||||
".",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/* One row of chrome and nothing else: the account on the left, the three boxes in the centre, and
|
||||
search, places and Write on the right. No sidebar, no folder tree, no toolbar.
|
||||
*
|
||||
* Three columns rather than a flex row with a spacer, so the boxes are centred in the window and
|
||||
* not in whatever is left of it after the account's name. */
|
||||
|
||||
.titlebar {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: var(--titlebar-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--shell);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The lane the macOS traffic lights are drawn in. Only one platform has them inside the page, and
|
||||
main.tsx says so on the root before the first paint. */
|
||||
:root[data-traffic] .titlebar {
|
||||
padding-left: var(--traffic-pad);
|
||||
}
|
||||
|
||||
.titlebar-lead,
|
||||
.titlebar-trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titlebar-trail {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px 0 6px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.account-chip:hover {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.account-chip .icon {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* What is happening, in the quietest voice the header has. Sentence case rather than the note's
|
||||
small caps, because this is a sentence and not a label, and it is a line to notice out of the
|
||||
corner of an eye rather than one to read. It ellipsises rather than wrapping: the header is one
|
||||
row tall and a mailbox that is busy must never be a mailbox that has moved. */
|
||||
.sync-busy {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The switcher behind the chip. */
|
||||
|
||||
.account-list {
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.account-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.account-option:hover,
|
||||
.account-option[data-active] {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.account-option .icon {
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.account-who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.account-address {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* Under the rule, because it is somewhere to go rather than one more account to be. */
|
||||
.account-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: calc(100% - 12px);
|
||||
margin: 4px 6px 6px;
|
||||
padding: 7px 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-3);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.account-settings:hover {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
:root[data-touch] .account-chip,
|
||||
:root[data-touch] .account-option,
|
||||
:root[data-touch] .account-settings {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* The help launcher and its menu. Layout only: every colour and radius is a token.
|
||||
*
|
||||
* The surface is on the wrapper rather than on the button, so the button stays the ghost the rest
|
||||
* of the app draws and keeps its own hover and its own active state without this file having to
|
||||
* restate either of them. */
|
||||
|
||||
.help-launcher {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
/* Over the stage, under the scrim an overlay puts up and under the compose card, which takes
|
||||
this corner and is the reason the button hides while it is open. */
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.help-launcher .button {
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
:root[data-phone] .help-launcher {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The menu. The frame, the surface and the shadow are the Popover primitive's; this is only the
|
||||
list inside it, and it is the More menu's list with an icon in front of each row. */
|
||||
|
||||
.help-menu {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.help-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
/* The arrow keys walk the rows, so the row the keyboard is on reads the way the row the pointer is
|
||||
over does. */
|
||||
.help-option:hover,
|
||||
.help-option:focus-visible {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.help-option .icon {
|
||||
flex: none;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.help-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-touch] .help-option {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* The invitation, as a card under the message that carried it.
|
||||
*
|
||||
* A calendar page on the left and the event on the right. The date is a block rather than a line of
|
||||
* the sentence because it is the one thing you look for: whether it is a day you can do. */
|
||||
|
||||
.invite {
|
||||
display: grid;
|
||||
grid-template-columns: var(--invite-date-w) 1fr;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--raised);
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.invite:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The card the keys are pointed at. A ring rather than a wash, the same way a Screener card takes
|
||||
one: the card is already the widest thing in the thread and tinting it would read as a quote. */
|
||||
.invite[data-focus] {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--card-ring);
|
||||
}
|
||||
|
||||
.invite-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.invite-date .mon {
|
||||
color: var(--ink-faint);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.invite-date .day {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invite-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-title {
|
||||
margin-bottom: 2px;
|
||||
font-size: var(--t-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invite-when {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.invite-organizer {
|
||||
margin-top: 2px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* One sentence, and then the button that fixes it. Not a banner and not a warning colour: the app
|
||||
is short a permission, which is a thing to grant rather than a thing that went wrong. */
|
||||
.invite-scope {
|
||||
margin: 10px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invite-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.invite-answered {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.invite-open {
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.invite-open:hover {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* The Kit's own layout, and nothing else. Every colour, radius and size that describes a primitive
|
||||
is in that primitive's stylesheet; what is here is the bench they sit on. */
|
||||
|
||||
/* The one page in the app that scrolls the document. app.css pins the body so the app itself never
|
||||
does, which is right for a window with its own scrolling regions and wrong for a page whose whole
|
||||
job is to be captured in a single image. */
|
||||
:root:has(.kit),
|
||||
:root:has(.kit) body,
|
||||
:root:has(.kit) #root {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:root:has(.kit) body {
|
||||
position: static;
|
||||
overflow: auto;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.kit {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 80px;
|
||||
}
|
||||
|
||||
.kit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.kit-heading {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.kit-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.kit-section {
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.kit-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.kit-note {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.kit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.kit-bench {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* A popover hangs out of the flow, so the bench it is anchored in keeps the room for it. */
|
||||
.kit-hang {
|
||||
min-height: 230px;
|
||||
}
|
||||
|
||||
/* The name of the state, in the same small uppercase every label in the family uses. */
|
||||
.kit-label {
|
||||
flex: none;
|
||||
width: 168px;
|
||||
padding-top: 6px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kit-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kit-quiet {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* A strip of paper with a hairline round it, so a row is seen on the surface it really sits on. */
|
||||
.kit-list {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kit-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.kit-form,
|
||||
.kit-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.kit-anchor {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.kit-icons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 14px 10px;
|
||||
}
|
||||
|
||||
.kit-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* A transform makes this the containing block for anything fixed inside it, which is what lets an
|
||||
overlay, a sheet and a toast be looked at in place rather than over the page. */
|
||||
.kit-stage {
|
||||
position: relative;
|
||||
transform: translate(0);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--shell);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kit-stage[data-short] {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.kit-stage[data-tall] {
|
||||
height: 720px;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* The two libraries, Clips and All files. Both borrow the list column's head so a place is read the
|
||||
same way wherever it is, and both take the whole stage because neither is a list of threads with
|
||||
a reading pane beside it. */
|
||||
|
||||
.library {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Clips
|
||||
----------------------------------------------------------------------------------------------- */
|
||||
|
||||
.clips-list {
|
||||
padding: 8px 0 40px;
|
||||
}
|
||||
|
||||
.clip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 18px;
|
||||
}
|
||||
|
||||
.clip-open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--r-md);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.clip-open:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
/* The passage in the text face, because it is somebody else's prose and it was worth keeping for
|
||||
the way it was written. The rule at its left is the quotation mark. */
|
||||
.clip-text {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--note-line);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--body-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.clip-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 8px 0 0 16px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.clip-sender {
|
||||
flex: none;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.clip-subject {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clip-date {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The delete is only offered where the pointer is: a library of things you chose to keep should
|
||||
not print a way to throw each of them away down its whole right edge. */
|
||||
.clip .button {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms var(--ease);
|
||||
}
|
||||
|
||||
.clip:hover .button,
|
||||
.clip .button:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-touch] .clip .button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* All files
|
||||
----------------------------------------------------------------------------------------------- */
|
||||
|
||||
.files-filters {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.files-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.files-sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.files-picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
color: var(--ink-faint);
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.files-picker:hover,
|
||||
.files-picker:focus-within {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.files-picker .icon {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.files-picker select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 4px 24px 4px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.files-picker select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--file-card-w), 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
text-align: left;
|
||||
transition: border-color 120ms var(--ease);
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* The type, in the same box the attachment chip in the pane draws, so a file is recognised the
|
||||
same way in both places. */
|
||||
.file-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-bottom: 7px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink-soft);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
:root[data-phone] .files-grid {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
:root[data-touch] .files-picker select {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* The stage and the list column.
|
||||
*
|
||||
* The stage is here rather than in a file of its own because it is nothing but the two columns'
|
||||
* shared box: a row, a paper background, and a list column of a fixed width beside a pane that
|
||||
* takes what is left. */
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.list-col {
|
||||
flex: none;
|
||||
width: var(--list-w);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
/* With the reading pane hidden the list takes the window and a thread opens in place. */
|
||||
:root[data-no-pane] .list-col {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
/* As short as the pill in it allows. The head says where you are and is then read once a day,
|
||||
so every pixel it does not need is a pixel of mail. */
|
||||
.list-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.list-blank {
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* An empty place whose mailbox is still arriving. The welcome screen's bar, at the list's own
|
||||
size, under the engine's sentence in the text face the empty state uses. */
|
||||
.list-filling {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-filling-line {
|
||||
margin: 0;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--t-4);
|
||||
}
|
||||
|
||||
.list-filling-bar {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: 3px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent-wash);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-filling-fill {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
transition: transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
/* Before there is a total the bar has nothing to fill against, so a short run of it walks the
|
||||
track instead: the same shape as the welcome screen's, and still a bar rather than a spinner. */
|
||||
.list-filling-bar[data-counting] .list-filling-fill {
|
||||
width: 40%;
|
||||
animation: list-filling-walk 1.4s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes list-filling-walk {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
.list-filling-count {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--t-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.list-filling-bar[data-counting] .list-filling-fill {
|
||||
width: 100%;
|
||||
animation: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Where a headed group ends and the plain list begins: Back sits above the Inbox, and without a
|
||||
line under it the Inbox reads as more of Back. */
|
||||
.list-item[data-rule] {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The one faint line a list closes with when it has run out of what is on the device. */
|
||||
.list-foot {
|
||||
margin: 0;
|
||||
padding: 14px 14px 20px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
/* The same line when the next page did not come, with the way to ask for it again beside it. */
|
||||
.list-foot[data-state="error"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* The More menu: the rest of the thread's verbs in a popover under the bar's last button, each with
|
||||
its key on the right the way the palette prints them. The frame, the surface and the shadow are
|
||||
the Popover primitive's; this is only the list inside it. */
|
||||
|
||||
.more-menu {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.more-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.more-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-3);
|
||||
text-align: left;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
/* The arrow keys walk the rows, so the row the keyboard is on reads the way the row the pointer is
|
||||
over does. */
|
||||
.more-option:hover,
|
||||
.more-option:focus-visible {
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
|
||||
.more-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more-empty {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
:root[data-touch] .more-option {
|
||||
min-height: var(--touch-h);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { placesSentence, withPlace } from "./notifyPlaces";
|
||||
|
||||
describe("withPlace", () => {
|
||||
it("turns a place on in the section's order rather than at the end", () => {
|
||||
expect(withPlace([], "feed", true)).toEqual(["feed"]);
|
||||
expect(withPlace(["feed"], "inbox", true)).toEqual(["inbox", "feed"]);
|
||||
expect(withPlace(["inbox", "feed"], "paper-trail", true)).toEqual(["inbox", "feed", "paper-trail"]);
|
||||
});
|
||||
|
||||
it("turns a place off and leaves the rest alone", () => {
|
||||
expect(withPlace(["inbox", "feed"], "inbox", false)).toEqual(["feed"]);
|
||||
expect(withPlace(["feed"], "inbox", false)).toEqual(["feed"]);
|
||||
});
|
||||
|
||||
it("does not list a place twice", () => {
|
||||
expect(withPlace(["inbox"], "inbox", true)).toEqual(["inbox"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placesSentence", () => {
|
||||
it("says nothing is on when nothing is", () => {
|
||||
expect(placesSentence([])).toBe("Nothing notifies you yet.");
|
||||
});
|
||||
|
||||
it("names one, two or three places as a sentence", () => {
|
||||
expect(placesSentence(["inbox"])).toBe("Notifications are on for the Inbox.");
|
||||
expect(placesSentence(["inbox", "feed"])).toBe("Notifications are on for the Inbox and the Feed.");
|
||||
expect(placesSentence(["feed", "inbox", "paper-trail"])).toBe(
|
||||
"Notifications are on for the Inbox, the Feed and the Paper Trail.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user