ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>ihasmail</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@ihasmail/web",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"short_name": "ihasmail",
|
||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f766e",
|
||||
"icons": [
|
||||
{ "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/img/icon-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Compose", "url": "/mail?compose=new", "description": "Write a new message" },
|
||||
{ "name": "Calendar", "url": "/calendar" },
|
||||
{ "name": "Contacts", "url": "/contacts" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* ihasmail service worker: app-shell caching for installability & fast loads.
|
||||
API requests are never cached. */
|
||||
const VERSION = "ihasmail-v2";
|
||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations & everything else: network-first, fall back to cached shell.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req).catch(() => caches.match("/")));
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Route, Switch, Redirect, useLocation } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { push } from "@/jmap/push";
|
||||
import { client } from "@/jmap/client";
|
||||
import { ToastHost } from "@/ui/toast";
|
||||
import { ConfirmHost } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { LoginPage } from "@/views/Login";
|
||||
import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { useSettings } from "@/store/settings";
|
||||
|
||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
||||
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
||||
|
||||
export function App() {
|
||||
const status = useSession((s) => s.status);
|
||||
const bootstrap = useSession((s) => s.bootstrap);
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthedApp() {
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const [location] = useLocation();
|
||||
|
||||
// Initial data + push wiring
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
const mail = useMail.getState();
|
||||
void mail.loadMailboxes();
|
||||
void mail.loadIdentities();
|
||||
void mail.loadQuota();
|
||||
void useContacts.getState().init();
|
||||
void useCalendar.getState().init();
|
||||
void useFiles.getState().init();
|
||||
void useSieve.getState().init();
|
||||
push.start();
|
||||
const pending = new Map<string, Set<string>>();
|
||||
let timer: number | null = null;
|
||||
const unsub = push.subscribe((acct, type) => {
|
||||
const set = pending.get(acct) ?? new Set<string>();
|
||||
set.add(type);
|
||||
pending.set(acct, set);
|
||||
if (timer) return;
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
for (const [a, types] of pending) {
|
||||
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
|
||||
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
|
||||
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
|
||||
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
|
||||
if (a === useSieve.getState().accountId) useSieve.getState().applyChanges(types);
|
||||
}
|
||||
pending.clear();
|
||||
}, 400);
|
||||
});
|
||||
const unsubState = client.onSessionState(() => void useSession.getState().refresh());
|
||||
// Poll fallback when push is disconnected (every 2 minutes)
|
||||
const poll = window.setInterval(() => {
|
||||
if (!push.connected && document.visibilityState === "visible") {
|
||||
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
|
||||
}
|
||||
}, 120_000);
|
||||
return () => {
|
||||
unsub();
|
||||
unsubState();
|
||||
window.clearInterval(poll);
|
||||
push.stop();
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
// Unread badge in title/favicon
|
||||
const inboxUnread = useMail((s) => {
|
||||
const id = s.roleId("inbox");
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
});
|
||||
}, [inboxUnread, appName]);
|
||||
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
useEffect(() => {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
}, [notif]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Suspense fallback={<Spinner size="lg" />}>
|
||||
<Switch>
|
||||
<Route path="/mail/:mailboxId?/:threadId?">{(p) => <MailView mailboxId={p.mailboxId} threadId={p.threadId} />}</Route>
|
||||
<Route path="/search/:threadId?">{(p) => <MailView search threadId={p.threadId} />}</Route>
|
||||
<Route path="/contacts/:id?">{(p) => <ContactsView id={p.id} />}</Route>
|
||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||
<Route path="/login">
|
||||
<Redirect to="/mail" />
|
||||
</Route>
|
||||
<Route>{location === "/" ? <Redirect to="/mail" /> : <Redirect to="/mail" />}</Route>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
<ComposerDock />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
|
||||
export const CAP = {
|
||||
core: "urn:ietf:params:jmap:core",
|
||||
mail: "urn:ietf:params:jmap:mail",
|
||||
submission: "urn:ietf:params:jmap:submission",
|
||||
vacation: "urn:ietf:params:jmap:vacationresponse",
|
||||
sieve: "urn:ietf:params:jmap:sieve",
|
||||
contacts: "urn:ietf:params:jmap:contacts",
|
||||
contactsParse: "urn:ietf:params:jmap:contacts:parse",
|
||||
calendars: "urn:ietf:params:jmap:calendars",
|
||||
calendarsParse: "urn:ietf:params:jmap:calendars:parse",
|
||||
principals: "urn:ietf:params:jmap:principals",
|
||||
availability: "urn:ietf:params:jmap:principals:availability",
|
||||
quota: "urn:ietf:params:jmap:quota",
|
||||
blob: "urn:ietf:params:jmap:blob",
|
||||
filenode: "urn:ietf:params:jmap:filenode",
|
||||
websocket: "urn:ietf:params:jmap:websocket",
|
||||
} as const;
|
||||
|
||||
export class JmapMethodError extends Error {
|
||||
constructor(
|
||||
public readonly method: string,
|
||||
public readonly error: MethodError,
|
||||
) {
|
||||
super(`${method}: ${error.type}${error.description ? ` - ${error.description}` : ""}`);
|
||||
this.name = "JmapMethodError";
|
||||
}
|
||||
get type() {
|
||||
return this.error.type;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `${code} (${status})`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error?: string;
|
||||
message?: string;
|
||||
type?: string;
|
||||
detail?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
using: Set<string>;
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export type ResultRef = { resultOf: string; name: string; path: string };
|
||||
|
||||
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/login")) {
|
||||
client.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated", "Your session has expired. Please sign in again.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: ApiErrorBody = {};
|
||||
try {
|
||||
body = (await res.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(res.status, body.error ?? body.type ?? "error", body.message ?? body.detail ?? body.title ?? res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export class JmapClient {
|
||||
session: JmapSession | null = null;
|
||||
private pending: Pending[] = [];
|
||||
private flushScheduled = false;
|
||||
private callCounter = 0;
|
||||
private unauthHandlers = new Set<() => void>();
|
||||
private stateHandlers = new Set<(sessionState: string) => void>();
|
||||
|
||||
get maxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
||||
return core?.maxCallsInRequest ?? 16;
|
||||
}
|
||||
|
||||
get maxObjectsInGet(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxObjectsInGet?: number } | undefined;
|
||||
return core?.maxObjectsInGet ?? 500;
|
||||
}
|
||||
|
||||
get maxSizeUpload(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined;
|
||||
return core?.maxSizeUpload ?? 50_000_000;
|
||||
}
|
||||
|
||||
hasCapability(cap: string): boolean {
|
||||
return Boolean(this.session?.capabilities && cap in this.session.capabilities);
|
||||
}
|
||||
|
||||
accountHasCapability(accountId: Id, cap: string): boolean {
|
||||
const acc = this.session?.accounts[accountId];
|
||||
return Boolean(acc && cap in acc.accountCapabilities);
|
||||
}
|
||||
|
||||
primaryAccount(cap: string): Id | null {
|
||||
return this.session?.primaryAccounts[cap] ?? null;
|
||||
}
|
||||
|
||||
onUnauthenticated(fn: () => void): () => void {
|
||||
this.unauthHandlers.add(fn);
|
||||
return () => this.unauthHandlers.delete(fn);
|
||||
}
|
||||
|
||||
onSessionState(fn: (s: string) => void): () => void {
|
||||
this.stateHandlers.add(fn);
|
||||
return () => this.stateHandlers.delete(fn);
|
||||
}
|
||||
|
||||
handleUnauthenticated(): void {
|
||||
for (const fn of this.unauthHandlers) fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a single method call; calls made within the same tick are batched
|
||||
* into one HTTP request (up to maxCallsInRequest).
|
||||
*/
|
||||
call<T = Record<string, unknown>>(method: string, args: Record<string, unknown>, using: string[] = []): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.push({
|
||||
method,
|
||||
args,
|
||||
using: new Set([CAP.core, ...usingFor(method), ...using]),
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
if (!this.flushScheduled) {
|
||||
this.flushScheduled = true;
|
||||
queueMicrotask(() => void this.flush());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
this.flushScheduled = false;
|
||||
const batch = this.pending;
|
||||
this.pending = [];
|
||||
const max = this.maxCallsInRequest;
|
||||
for (let i = 0; i < batch.length; i += max) {
|
||||
void this.sendBatch(batch.slice(i, i + max));
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBatch(batch: Pending[]): Promise<void> {
|
||||
const using = new Set<string>();
|
||||
const calls: Invocation[] = batch.map((p, idx) => {
|
||||
for (const u of p.using) using.add(u);
|
||||
return [p.method, p.args, `c${this.callCounter++}_${idx}`];
|
||||
});
|
||||
try {
|
||||
const res = await this.request(calls, [...using]);
|
||||
const byId = new Map<string, Invocation[]>();
|
||||
for (const inv of res.methodResponses) {
|
||||
const arr = byId.get(inv[2]) ?? [];
|
||||
arr.push(inv);
|
||||
byId.set(inv[2], arr);
|
||||
}
|
||||
batch.forEach((p, idx) => {
|
||||
const responses = byId.get(calls[idx]![2]);
|
||||
const first = responses?.[0];
|
||||
if (!first) {
|
||||
p.reject(new JmapMethodError(p.method, { type: "serverFail", description: "No response for call" }));
|
||||
return;
|
||||
}
|
||||
if (first[0] === "error") p.reject(new JmapMethodError(p.method, first[1] as MethodError));
|
||||
else p.resolve(first[1]);
|
||||
});
|
||||
} catch (err) {
|
||||
for (const p of batch) p.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Low-level request: send invocations verbatim, return raw response. */
|
||||
async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> {
|
||||
const body: Record<string, unknown> = { using, methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state) {
|
||||
for (const fn of this.stateHandlers) fn(res.sessionState);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a chain of invocations (which may use result references) and return
|
||||
* responses keyed by call id. Throws if any call errored, unless `allowErrors`.
|
||||
*/
|
||||
async chain(
|
||||
calls: Array<[method: string, args: Record<string, unknown>, id: string]>,
|
||||
opts: { using?: string[]; allowErrors?: boolean } = {},
|
||||
): Promise<Map<string, Record<string, unknown>[]>> {
|
||||
const using = new Set<string>([CAP.core]);
|
||||
for (const [m] of calls) for (const u of usingFor(m)) using.add(u);
|
||||
for (const u of opts.using ?? []) using.add(u);
|
||||
const res = await this.request(calls, [...using]);
|
||||
const out = new Map<string, Record<string, unknown>[]>();
|
||||
for (const [name, args, id] of res.methodResponses) {
|
||||
if (name === "error" && !opts.allowErrors) {
|
||||
const method = calls.find((c) => c[2] === id)?.[0] ?? id;
|
||||
throw new JmapMethodError(method, args as MethodError);
|
||||
}
|
||||
const arr = out.get(id) ?? [];
|
||||
arr.push(name === "error" ? { __error: args } : args);
|
||||
out.set(id, arr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
uploadUrl(accountId: Id): string {
|
||||
return `/api/upload/${encodeURIComponent(accountId)}`;
|
||||
}
|
||||
|
||||
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
||||
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
||||
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
|
||||
return inline ? `${u}&inline=1` : u;
|
||||
}
|
||||
|
||||
/** Upload a blob with progress reporting (XHR because fetch lacks upload progress). */
|
||||
upload(
|
||||
accountId: Id,
|
||||
data: Blob,
|
||||
opts: { type?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<UploadResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", this.uploadUrl(accountId));
|
||||
xhr.setRequestHeader("content-type", opts.type || data.type || "application/octet-stream");
|
||||
xhr.setRequestHeader("x-requested-with", "ihasmail");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) opts.onProgress?.(e.loaded, e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
reject(new ApiError(401, "unauthenticated"));
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && xhr.response) resolve(xhr.response as UploadResponse);
|
||||
else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed"));
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload"));
|
||||
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled"));
|
||||
opts.signal?.addEventListener("abort", () => xhr.abort());
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a blob's content as text (via the download proxy). */
|
||||
async fetchBlobText(accountId: Id, blobId: Id, type = "text/plain"): Promise<string> {
|
||||
const res = await fetch(this.downloadUrl(accountId, blobId, "blob.txt", type), { credentials: "same-origin" });
|
||||
if (res.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated");
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, "download_failed");
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async fetchBlob(accountId: Id, blobId: Id, type = "application/octet-stream"): Promise<Blob> {
|
||||
const res = await fetch(this.downloadUrl(accountId, blobId, "blob", type), { credentials: "same-origin" });
|
||||
if (res.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated");
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, "download_failed");
|
||||
return await res.blob();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map method name prefix → required capability URNs. */
|
||||
function usingFor(method: string): string[] {
|
||||
const type = method.split("/")[0] ?? "";
|
||||
switch (type) {
|
||||
case "Mailbox":
|
||||
case "Thread":
|
||||
case "Email":
|
||||
case "SearchSnippet":
|
||||
case "Identity":
|
||||
return [CAP.mail];
|
||||
case "EmailSubmission":
|
||||
return [CAP.mail, CAP.submission];
|
||||
case "VacationResponse":
|
||||
return [CAP.mail, CAP.vacation];
|
||||
case "SieveScript":
|
||||
return [CAP.sieve];
|
||||
case "AddressBook":
|
||||
case "ContactCard":
|
||||
return [CAP.contacts, CAP.contactsParse];
|
||||
case "Calendar":
|
||||
case "CalendarEvent":
|
||||
case "ParticipantIdentity":
|
||||
case "CalendarEventNotification":
|
||||
return [CAP.calendars, CAP.calendarsParse];
|
||||
case "Principal":
|
||||
return [CAP.principals, CAP.availability];
|
||||
case "Quota":
|
||||
return [CAP.quota];
|
||||
case "Blob":
|
||||
return [CAP.blob];
|
||||
case "FileNode":
|
||||
return [CAP.filenode];
|
||||
case "PushSubscription":
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const client = new JmapClient();
|
||||
|
||||
/** Build a JMAP result reference argument ("#ids": {...}). */
|
||||
export function ref(resultOf: string, name: string, path: string): ResultRef {
|
||||
return { resultOf, name, path };
|
||||
}
|
||||
|
||||
/** Chunk ids for /get calls to respect maxObjectsInGet. */
|
||||
export function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Id, StateChange } from "./types";
|
||||
|
||||
export type PushListener = (accountId: Id, type: string, newState: string) => void;
|
||||
|
||||
/**
|
||||
* JMAP push over Server-Sent Events (proxied through our server).
|
||||
* Emits per-type state changes so stores can refresh incrementally.
|
||||
*/
|
||||
class PushManager {
|
||||
private es: EventSource | null = null;
|
||||
private listeners = new Set<PushListener>();
|
||||
private connectionListeners = new Set<(connected: boolean) => void>();
|
||||
private backoff = 1000;
|
||||
private reconnectTimer: number | null = null;
|
||||
private stopped = true;
|
||||
private lastStates = new Map<string, string>();
|
||||
connected = false;
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
document.addEventListener("visibilitychange", this.onVisibility);
|
||||
window.addEventListener("online", this.onOnline);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
document.removeEventListener("visibilitychange", this.onVisibility);
|
||||
window.removeEventListener("online", this.onOnline);
|
||||
if (this.reconnectTimer) window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.es?.close();
|
||||
this.es = null;
|
||||
this.setConnected(false);
|
||||
}
|
||||
|
||||
subscribe(fn: PushListener): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
onConnection(fn: (connected: boolean) => void): () => void {
|
||||
this.connectionListeners.add(fn);
|
||||
return () => this.connectionListeners.delete(fn);
|
||||
}
|
||||
|
||||
private setConnected(v: boolean) {
|
||||
if (this.connected === v) return;
|
||||
this.connected = v;
|
||||
for (const fn of this.connectionListeners) fn(v);
|
||||
}
|
||||
|
||||
private onVisibility = () => {
|
||||
if (document.visibilityState === "visible" && !this.es && !this.stopped) this.connect();
|
||||
};
|
||||
|
||||
private onOnline = () => {
|
||||
if (!this.es && !this.stopped) this.connect();
|
||||
};
|
||||
|
||||
private connect(): void {
|
||||
if (this.stopped || this.es) return;
|
||||
const url = `/api/events?types=*&closeafter=no&ping=30`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
es.onopen = () => {
|
||||
this.backoff = 1000;
|
||||
this.setConnected(true);
|
||||
};
|
||||
es.addEventListener("state", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data as string) as StateChange;
|
||||
if (data["@type"] !== "StateChange") return;
|
||||
for (const [accountId, types] of Object.entries(data.changed)) {
|
||||
for (const [type, state] of Object.entries(types)) {
|
||||
const key = `${accountId}/${type}`;
|
||||
if (this.lastStates.get(key) === state) continue;
|
||||
this.lastStates.set(key, state);
|
||||
for (const fn of this.listeners) fn(accountId, type, state);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
/* keepalive */
|
||||
});
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
this.es = null;
|
||||
this.setConnected(false);
|
||||
if (this.stopped) return;
|
||||
const delay = Math.min(this.backoff, 60_000);
|
||||
this.backoff = Math.min(this.backoff * 2, 60_000);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const push = new PushManager();
|
||||
@@ -0,0 +1,775 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* JMAP core (RFC 8620) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type Id = string;
|
||||
export type UTCDate = string; // "2024-01-01T10:00:00Z"
|
||||
export type LocalDate = string; // "2024-01-01T10:00:00"
|
||||
|
||||
export interface Account {
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
isReadOnly: boolean;
|
||||
accountCapabilities: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JmapSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
accounts: Record<Id, Account>;
|
||||
primaryAccounts: Record<string, Id>;
|
||||
username: string;
|
||||
apiUrl: string;
|
||||
downloadUrl: string;
|
||||
uploadUrl: string;
|
||||
eventSourceUrl: string;
|
||||
state: string;
|
||||
ihasmail?: {
|
||||
appName: string;
|
||||
imageProxy: boolean;
|
||||
maxUploadBytes: number;
|
||||
sessionId: string;
|
||||
loginName: string;
|
||||
remember: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoreCapabilities {
|
||||
maxSizeUpload: number;
|
||||
maxConcurrentUpload: number;
|
||||
maxSizeRequest: number;
|
||||
maxConcurrentRequests: number;
|
||||
maxCallsInRequest: number;
|
||||
maxObjectsInGet: number;
|
||||
maxObjectsInSet: number;
|
||||
collationAlgorithms: string[];
|
||||
}
|
||||
|
||||
export interface MailCapabilities {
|
||||
maxMailboxesPerEmail: number | null;
|
||||
maxMailboxDepth: number | null;
|
||||
maxSizeMailboxName: number;
|
||||
maxSizeAttachmentsPerEmail: number;
|
||||
emailQuerySortOptions: string[];
|
||||
mayCreateTopLevelMailbox: boolean;
|
||||
}
|
||||
|
||||
export type Invocation = [name: string, args: Record<string, unknown>, callId: string];
|
||||
|
||||
export interface JmapResponse {
|
||||
methodResponses: Invocation[];
|
||||
sessionState: string;
|
||||
createdIds?: Record<string, Id>;
|
||||
}
|
||||
|
||||
export interface MethodError {
|
||||
type: string;
|
||||
description?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SetError {
|
||||
type: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SetResponse<T = Record<string, unknown>> {
|
||||
accountId: Id;
|
||||
oldState: string | null;
|
||||
newState: string;
|
||||
created?: Record<string, T>;
|
||||
updated?: Record<string, T | null>;
|
||||
destroyed?: Id[];
|
||||
notCreated?: Record<string, SetError>;
|
||||
notUpdated?: Record<string, SetError>;
|
||||
notDestroyed?: Record<string, SetError>;
|
||||
}
|
||||
|
||||
export interface GetResponse<T> {
|
||||
accountId: Id;
|
||||
state: string;
|
||||
list: T[];
|
||||
notFound: Id[];
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
accountId: Id;
|
||||
queryState: string;
|
||||
canCalculateChanges: boolean;
|
||||
position: number;
|
||||
ids: Id[];
|
||||
total?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ChangesResponse {
|
||||
accountId: Id;
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: Id[];
|
||||
updated: Id[];
|
||||
destroyed: Id[];
|
||||
}
|
||||
|
||||
export interface StateChange {
|
||||
"@type": "StateChange";
|
||||
changed: Record<Id, Record<string, string>>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mail (RFC 8621) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type MailboxRole =
|
||||
| "inbox"
|
||||
| "archive"
|
||||
| "drafts"
|
||||
| "sent"
|
||||
| "trash"
|
||||
| "junk"
|
||||
| "important"
|
||||
| "all"
|
||||
| "flagged"
|
||||
| "subscribed"
|
||||
| null;
|
||||
|
||||
export interface MailboxRights {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: Id;
|
||||
name: string;
|
||||
parentId: Id | null;
|
||||
role: MailboxRole;
|
||||
sortOrder: number;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: MailboxRights;
|
||||
isSubscribed: boolean;
|
||||
shareWith?: Record<Id, Partial<MailboxRights>> | null;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface EmailAddressGroup {
|
||||
name: string | null;
|
||||
addresses: EmailAddress[];
|
||||
}
|
||||
|
||||
export interface EmailHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EmailBodyPart {
|
||||
partId: string | null;
|
||||
blobId: Id | null;
|
||||
size: number;
|
||||
headers?: EmailHeader[];
|
||||
name: string | null;
|
||||
type: string;
|
||||
charset: string | null;
|
||||
disposition: string | null;
|
||||
cid: string | null;
|
||||
language?: string[] | null;
|
||||
location?: string | null;
|
||||
subParts?: EmailBodyPart[] | null;
|
||||
}
|
||||
|
||||
export interface EmailBodyValue {
|
||||
value: string;
|
||||
isEncodingProblem: boolean;
|
||||
isTruncated: boolean;
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
id: Id;
|
||||
blobId: Id;
|
||||
threadId: Id;
|
||||
mailboxIds: Record<Id, boolean>;
|
||||
keywords: Record<string, boolean>;
|
||||
size: number;
|
||||
receivedAt: UTCDate;
|
||||
messageId?: string[] | null;
|
||||
inReplyTo?: string[] | null;
|
||||
references?: string[] | null;
|
||||
sender?: EmailAddress[] | null;
|
||||
from?: EmailAddress[] | null;
|
||||
to?: EmailAddress[] | null;
|
||||
cc?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
subject?: string | null;
|
||||
sentAt?: string | null;
|
||||
hasAttachment?: boolean;
|
||||
preview?: string;
|
||||
bodyStructure?: EmailBodyPart;
|
||||
bodyValues?: Record<string, EmailBodyValue>;
|
||||
textBody?: EmailBodyPart[];
|
||||
htmlBody?: EmailBodyPart[];
|
||||
attachments?: EmailBodyPart[];
|
||||
headers?: EmailHeader[];
|
||||
// convenience header fetches
|
||||
"header:List-Unsubscribe:asText"?: string | null;
|
||||
"header:List-Unsubscribe-Post:asText"?: string | null;
|
||||
"header:List-Id:asText"?: string | null;
|
||||
"header:Disposition-Notification-To:asAddresses"?: EmailAddress[] | null;
|
||||
"header:X-Priority:asText"?: string | null;
|
||||
"header:Importance:asText"?: string | null;
|
||||
"header:Auto-Submitted:asText"?: string | null;
|
||||
"header:Return-Path:asText"?: string | null;
|
||||
"header:Authentication-Results:asText"?: string | null;
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
"header:X-Spam-Status:asText"?: string | null;
|
||||
"header:X-Spam-Result:asText"?: string | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: Id;
|
||||
emailIds: Id[];
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: Id;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: EmailAddress[] | null;
|
||||
bcc: EmailAddress[] | null;
|
||||
textSignature: string;
|
||||
htmlSignature: string;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: Id;
|
||||
identityId: Id;
|
||||
emailId: Id;
|
||||
threadId: Id;
|
||||
envelope: { mailFrom: { email: string; parameters?: Record<string, unknown> | null }; rcptTo: { email: string }[] } | null;
|
||||
sendAt: UTCDate;
|
||||
undoStatus: "pending" | "final" | "canceled";
|
||||
deliveryStatus: Record<string, { smtpReply: string; delivered: string; displayed: string }> | null;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: "singleton";
|
||||
isEnabled: boolean;
|
||||
fromDate: UTCDate | null;
|
||||
toDate: UTCDate | null;
|
||||
subject: string | null;
|
||||
textBody: string | null;
|
||||
htmlBody: string | null;
|
||||
}
|
||||
|
||||
export interface SearchSnippet {
|
||||
emailId: Id;
|
||||
subject: string | null;
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface EmailFilterCondition {
|
||||
inMailbox?: Id;
|
||||
inMailboxOtherThan?: Id[];
|
||||
before?: UTCDate;
|
||||
after?: UTCDate;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
allInThreadHaveKeyword?: string;
|
||||
someInThreadHaveKeyword?: string;
|
||||
noneInThreadHaveKeyword?: string;
|
||||
hasKeyword?: string;
|
||||
notKeyword?: string;
|
||||
hasAttachment?: boolean;
|
||||
text?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
header?: string[];
|
||||
}
|
||||
|
||||
export interface FilterOperator<T> {
|
||||
operator: "AND" | "OR" | "NOT";
|
||||
conditions: Array<T | FilterOperator<T>>;
|
||||
}
|
||||
|
||||
export type EmailFilter = EmailFilterCondition | FilterOperator<EmailFilterCondition>;
|
||||
|
||||
export interface Comparator {
|
||||
property: string;
|
||||
isAscending?: boolean;
|
||||
collation?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Quota (RFC 9425) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface Quota {
|
||||
id: Id;
|
||||
resourceType: "count" | "octets";
|
||||
used: number;
|
||||
hardLimit: number;
|
||||
scope: "account" | "domain" | "global";
|
||||
name: string;
|
||||
types: string[];
|
||||
warnLimit?: number | null;
|
||||
softLimit?: number | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sieve (RFC 9661) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface SieveScript {
|
||||
id: Id;
|
||||
name: string;
|
||||
blobId: Id;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Principals (RFC 9670) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface Principal {
|
||||
id: Id;
|
||||
type: "individual" | "group" | "resource" | "location" | "other";
|
||||
name: string;
|
||||
description: string | null;
|
||||
email: string | null;
|
||||
timeZone: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
accounts?: Record<Id, Account> | null;
|
||||
}
|
||||
|
||||
export interface BusyPeriod {
|
||||
utcStart: UTCDate;
|
||||
utcEnd: UTCDate;
|
||||
busyStatus: "confirmed" | "tentative" | "unavailable";
|
||||
event: JSCalendarEvent | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Contacts (RFC 9610 / JSContact RFC 9553) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface AddressBook {
|
||||
id: Id;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
isSubscribed: boolean;
|
||||
shareWith: Record<Id, AddressBookRights> | null;
|
||||
myRights: AddressBookRights;
|
||||
}
|
||||
|
||||
export interface JSContactNameComponent {
|
||||
"@type"?: "NameComponent";
|
||||
kind: "title" | "given" | "given2" | "surname" | "surname2" | "credential" | "generation" | "separator";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface JSContactName {
|
||||
"@type"?: "Name";
|
||||
components?: JSContactNameComponent[];
|
||||
isOrdered?: boolean;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
sortAs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface JSContactEmail {
|
||||
"@type"?: "EmailAddress";
|
||||
address: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactPhone {
|
||||
"@type"?: "Phone";
|
||||
number: string;
|
||||
features?: Record<string, boolean>;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactAddressComponent {
|
||||
"@type"?: "AddressComponent";
|
||||
kind: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface JSContactAddress {
|
||||
"@type"?: "Address";
|
||||
components?: JSContactAddressComponent[];
|
||||
isOrdered?: boolean;
|
||||
countryCode?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface JSContactOrganization {
|
||||
"@type"?: "Organization";
|
||||
name?: string;
|
||||
units?: { "@type"?: "OrgUnit"; name: string }[];
|
||||
sortAs?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface JSContactTitle {
|
||||
"@type"?: "Title";
|
||||
name: string;
|
||||
kind?: "title" | "role";
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export interface JSContactAnniversary {
|
||||
"@type"?: "Anniversary";
|
||||
kind: "birth" | "death" | "wedding" | string;
|
||||
date: { "@type"?: "PartialDate" | "Timestamp"; year?: number; month?: number; day?: number; utc?: string };
|
||||
place?: JSContactAddress;
|
||||
}
|
||||
|
||||
export interface JSContactNote {
|
||||
"@type"?: "Note";
|
||||
note: string;
|
||||
created?: string;
|
||||
author?: { name?: string; uri?: string };
|
||||
}
|
||||
|
||||
export interface JSContactOnlineService {
|
||||
"@type"?: "OnlineService";
|
||||
service?: string;
|
||||
uri?: string;
|
||||
user?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactMedia {
|
||||
"@type"?: "Media";
|
||||
kind: "photo" | "sound" | "logo";
|
||||
uri?: string;
|
||||
blobId?: Id;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactRelation {
|
||||
"@type"?: "Relation";
|
||||
relation?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: Id;
|
||||
addressBookIds: Record<Id, boolean>;
|
||||
"@type"?: "Card";
|
||||
version?: "1.0";
|
||||
uid: string;
|
||||
kind?: "individual" | "group" | "org" | "location" | "device" | "application";
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
language?: string;
|
||||
prodId?: string;
|
||||
members?: Record<string, boolean>;
|
||||
name?: JSContactName;
|
||||
nicknames?: Record<string, { "@type"?: "Nickname"; name: string; contexts?: Record<string, boolean>; pref?: number }>;
|
||||
organizations?: Record<string, JSContactOrganization>;
|
||||
titles?: Record<string, JSContactTitle>;
|
||||
emails?: Record<string, JSContactEmail>;
|
||||
phones?: Record<string, JSContactPhone>;
|
||||
addresses?: Record<string, JSContactAddress>;
|
||||
onlineServices?: Record<string, JSContactOnlineService>;
|
||||
anniversaries?: Record<string, JSContactAnniversary>;
|
||||
notes?: Record<string, JSContactNote>;
|
||||
keywords?: Record<string, boolean>;
|
||||
media?: Record<string, JSContactMedia>;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
links?: Record<string, { "@type"?: "Link"; uri: string; kind?: string; label?: string }>;
|
||||
preferredLanguages?: Record<string, { "@type"?: "LanguagePref"; language: string; pref?: number; contexts?: Record<string, boolean> }>;
|
||||
speakToAs?: { "@type"?: "SpeakToAs"; grammaticalGender?: string; pronouns?: Record<string, { pronouns: string }> };
|
||||
calendars?: Record<string, { "@type"?: "Calendar"; kind?: string; uri: string }>;
|
||||
schedulingAddresses?: Record<string, { "@type"?: "SchedulingAddress"; uri: string }>;
|
||||
personalInfo?: Record<string, { "@type"?: "PersonalInfo"; kind: string; value: string; level?: string }>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Calendars (draft-ietf-jmap-calendars / JSCalendar RFC 8984) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy: boolean;
|
||||
mayReadItems: boolean;
|
||||
mayWriteAll: boolean;
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: Id;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
isSubscribed: boolean;
|
||||
isVisible: boolean;
|
||||
isDefault: boolean;
|
||||
includeInAvailability: "all" | "attending" | "none";
|
||||
defaultAlertsWithTime: Record<string, JSCalendarAlert> | null;
|
||||
defaultAlertsWithoutTime: Record<string, JSCalendarAlert> | null;
|
||||
timeZone: string | null;
|
||||
shareWith: Record<Id, CalendarRights> | null;
|
||||
myRights: CalendarRights;
|
||||
}
|
||||
|
||||
export interface JSCalendarAlert {
|
||||
"@type"?: "Alert";
|
||||
trigger:
|
||||
| { "@type"?: "OffsetTrigger"; offset: string; relativeTo?: "start" | "end" }
|
||||
| { "@type"?: "AbsoluteTrigger"; when: UTCDate };
|
||||
acknowledged?: UTCDate;
|
||||
action?: "display" | "email";
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
}
|
||||
|
||||
export interface JSCalendarNDay {
|
||||
"@type"?: "NDay";
|
||||
day: "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
||||
nthOfPeriod?: number;
|
||||
}
|
||||
|
||||
export interface JSCalendarRecurrenceRule {
|
||||
"@type"?: "RecurrenceRule";
|
||||
frequency: "yearly" | "monthly" | "weekly" | "daily" | "hourly" | "minutely" | "secondly";
|
||||
interval?: number;
|
||||
rscale?: string;
|
||||
skip?: string;
|
||||
firstDayOfWeek?: string;
|
||||
byDay?: JSCalendarNDay[];
|
||||
byMonthDay?: number[];
|
||||
byMonth?: string[];
|
||||
byYearDay?: number[];
|
||||
byWeekNo?: number[];
|
||||
byHour?: number[];
|
||||
byMinute?: number[];
|
||||
bySecond?: number[];
|
||||
bySetPosition?: number[];
|
||||
count?: number;
|
||||
until?: LocalDate;
|
||||
}
|
||||
|
||||
export interface JSCalendarParticipant {
|
||||
"@type"?: "Participant";
|
||||
name?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
kind?: "individual" | "group" | "location" | "resource";
|
||||
roles: Record<string, boolean>;
|
||||
locationId?: string;
|
||||
language?: string;
|
||||
participationStatus?: "needs-action" | "accepted" | "declined" | "tentative" | "delegated";
|
||||
participationComment?: string;
|
||||
expectReply?: boolean;
|
||||
scheduleAgent?: "server" | "client" | "none";
|
||||
scheduleForceSend?: boolean;
|
||||
scheduleSequence?: number;
|
||||
scheduleStatus?: string[];
|
||||
scheduleUpdated?: UTCDate;
|
||||
sentBy?: string;
|
||||
invitedBy?: string;
|
||||
delegatedTo?: Record<string, boolean>;
|
||||
delegatedFrom?: Record<string, boolean>;
|
||||
memberOf?: Record<string, boolean>;
|
||||
links?: Record<string, unknown>;
|
||||
progress?: string;
|
||||
percentComplete?: number;
|
||||
}
|
||||
|
||||
export interface JSCalendarLocation {
|
||||
"@type"?: "Location";
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: "start" | "end";
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
links?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JSCalendarVirtualLocation {
|
||||
"@type"?: "VirtualLocation";
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface JSCalendarEvent {
|
||||
"@type"?: "Event";
|
||||
uid: string;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
prodId?: string;
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
sequence?: number;
|
||||
method?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
descriptionContentType?: string;
|
||||
showWithoutTime?: boolean;
|
||||
locations?: Record<string, JSCalendarLocation>;
|
||||
virtualLocations?: Record<string, JSCalendarVirtualLocation>;
|
||||
links?: Record<string, { "@type"?: "Link"; href: string; contentType?: string; size?: number; rel?: string; display?: string; title?: string }>;
|
||||
locale?: string;
|
||||
keywords?: Record<string, boolean>;
|
||||
categories?: Record<string, boolean>;
|
||||
color?: string;
|
||||
recurrenceId?: LocalDate;
|
||||
recurrenceIdTimeZone?: string;
|
||||
recurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
|
||||
excluded?: boolean;
|
||||
priority?: number;
|
||||
freeBusyStatus?: "free" | "busy";
|
||||
privacy?: "public" | "private" | "secret";
|
||||
replyTo?: Record<string, string>;
|
||||
sentBy?: string;
|
||||
participants?: Record<string, JSCalendarParticipant>;
|
||||
requestStatus?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, JSCalendarAlert>;
|
||||
localizations?: Record<string, Record<string, unknown>>;
|
||||
timeZone?: string | null;
|
||||
start: LocalDate;
|
||||
duration?: string;
|
||||
status?: "confirmed" | "cancelled" | "tentative";
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends JSCalendarEvent {
|
||||
id: Id;
|
||||
baseEventId?: Id | null;
|
||||
calendarIds: Record<Id, boolean>;
|
||||
isDraft?: boolean;
|
||||
isOrigin?: boolean;
|
||||
utcStart?: UTCDate;
|
||||
utcEnd?: UTCDate;
|
||||
mayInviteSelf?: boolean;
|
||||
mayInviteOthers?: boolean;
|
||||
hideAttendees?: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantIdentity {
|
||||
id: Id;
|
||||
name: string;
|
||||
calendarAddress: string;
|
||||
sendTo: Record<string, string>;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEventNotification {
|
||||
id: Id;
|
||||
created: UTCDate;
|
||||
changedBy: { name: string; email: string | null; principalId: Id | null; calendarAddress?: string | null };
|
||||
comment: string | null;
|
||||
type: "created" | "updated" | "destroyed";
|
||||
calendarEventId: Id;
|
||||
isDraft?: boolean;
|
||||
event: JSCalendarEvent;
|
||||
eventPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Files (draft-ietf-jmap-filenode) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface FilesRights {
|
||||
mayRead: boolean;
|
||||
mayAddChildren: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
mayModifyContent: boolean;
|
||||
mayShare: boolean;
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
id: Id;
|
||||
parentId: Id | null;
|
||||
nodeType: "file" | "directory" | "symlink";
|
||||
blobId: Id | null;
|
||||
target?: string[] | null;
|
||||
size: number | null;
|
||||
name: string;
|
||||
type: string | null;
|
||||
created: UTCDate;
|
||||
modified: UTCDate | null;
|
||||
accessed?: UTCDate | null;
|
||||
changed?: UTCDate;
|
||||
executable?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights: FilesRights;
|
||||
shareWith?: Record<Id, FilesRights> | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Blob (RFC 9404) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface UploadResponse {
|
||||
accountId: Id;
|
||||
blobId: Id;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BlobGetResponse {
|
||||
id: Id;
|
||||
"data:asText"?: string | null;
|
||||
"data:asBase64"?: string | null;
|
||||
size?: number;
|
||||
isEncodingProblem?: boolean;
|
||||
isTruncated?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAddress, initials, isValidEmail, parseAddressList } from "../address";
|
||||
|
||||
describe("address parsing", () => {
|
||||
it("parses mixed lists", () => {
|
||||
const list = parseAddressList('Ann Example <[email protected]>, [email protected]; "Smith, John" <[email protected]>');
|
||||
expect(list).toEqual([
|
||||
{ name: "Ann Example", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Smith, John", email: "[email protected]" },
|
||||
]);
|
||||
});
|
||||
it("formats with quoting when needed", () => {
|
||||
expect(formatAddress({ name: "Smith, John", email: "[email protected]" })).toBe('"Smith, John" <[email protected]>');
|
||||
expect(formatAddress({ name: null, email: "[email protected]" })).toBe("[email protected]");
|
||||
});
|
||||
it("validates and initials", () => {
|
||||
expect(isValidEmail("[email protected]")).toBe(true);
|
||||
expect(isValidEmail("nope")).toBe(false);
|
||||
expect(initials({ name: "Grace Hopper", email: "" })).toBe("GH");
|
||||
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDuration, parseDuration, zonedToDate, dateToZonedLocal, monthGrid } from "../dates";
|
||||
|
||||
describe("dates", () => {
|
||||
it("parses and formats ISO durations", () => {
|
||||
expect(parseDuration("PT1H30M")).toBe(5400);
|
||||
expect(parseDuration("P1DT2H")).toBe(93600);
|
||||
expect(parseDuration("-PT15M")).toBe(-900);
|
||||
expect(formatDuration(5400)).toBe("PT1H30M");
|
||||
expect(formatDuration(-600)).toBe("-PT10M");
|
||||
expect(formatDuration(86400)).toBe("P1D");
|
||||
});
|
||||
it("converts zoned local times to instants", () => {
|
||||
const d = zonedToDate("2024-07-01T12:00:00", "America/New_York");
|
||||
expect(d.toISOString()).toBe("2024-07-01T16:00:00.000Z");
|
||||
expect(dateToZonedLocal(d, "Europe/Berlin")).toBe("2024-07-01T18:00:00");
|
||||
});
|
||||
it("builds a 42-day month grid starting on week start", () => {
|
||||
const g = monthGrid(new Date(2024, 1, 15), 1);
|
||||
expect(g).toHaveLength(42);
|
||||
expect(g[0]!.getDay()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "../html";
|
||||
|
||||
describe("sanitizeEmailHtml", () => {
|
||||
it("removes scripts and event handlers", () => {
|
||||
const r = sanitizeEmailHtml('<div onclick="x()">hi<script>alert(1)</script><iframe src="https://evil"></iframe></div>');
|
||||
expect(r.html).not.toContain("script");
|
||||
expect(r.html).not.toContain("onclick");
|
||||
expect(r.html).not.toContain("iframe");
|
||||
});
|
||||
it("blocks remote images until allowed and maps cid", () => {
|
||||
const src = '<img src="https://t.example/p.gif"><img src="cid:logo@x"><div style="background:url(https://t.example/b.png)">x</div>';
|
||||
const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } });
|
||||
expect(blocked.remoteCount).toBe(2);
|
||||
expect(blocked.html).toContain('data-ihm-blocked="1"');
|
||||
expect(blocked.html).toContain("/api/blob/a/b/logo.png");
|
||||
expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/);
|
||||
expect(blocked.html).not.toContain("url(https://t.example");
|
||||
const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true });
|
||||
expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif");
|
||||
});
|
||||
it("forces links to open in new tabs", () => {
|
||||
const r = sanitizeEmailHtml('<a href="https://x.io">x</a>');
|
||||
expect(r.html).toContain('target="_blank"');
|
||||
expect(r.html).toContain("noopener");
|
||||
});
|
||||
it("strips javascript: urls", () => {
|
||||
const r = sanitizeEmailHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(r.html).not.toContain("javascript:");
|
||||
});
|
||||
it("editor sanitizer keeps basic formatting", () => {
|
||||
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFilter, parseQuery } from "../search";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
const mb = (id: string, name: string, role: Mailbox["role"] = null): Mailbox =>
|
||||
({ id, name, role, parentId: null, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] });
|
||||
|
||||
describe("parseQuery", () => {
|
||||
it("parses gmail-style operators", () => {
|
||||
const p = parseQuery('from:ada subject:"q3 plan" has:attachment is:unread in:work before:2024-01-02 larger:2M hello world');
|
||||
expect(p.from).toBe("ada");
|
||||
expect(p.subject).toBe("q3 plan");
|
||||
expect(p.hasAttachment).toBe(true);
|
||||
expect(p.unread).toBe(true);
|
||||
expect(p.in).toBe("work");
|
||||
expect(p.before).toMatch(/^2024-01-0[12]T/);
|
||||
expect(p.larger).toBe(2 * 1024 * 1024);
|
||||
expect(p.text).toEqual(["hello", "world"]);
|
||||
});
|
||||
it("handles labels and negation", () => {
|
||||
const p = parseQuery("label:work -label:done is:starred");
|
||||
expect(p.label).toEqual(["work"]);
|
||||
expect(p.notLabel).toEqual(["done"]);
|
||||
expect(p.starred).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFilter", () => {
|
||||
const mailboxes = { inbox: mb("inbox", "Inbox", "inbox"), work: mb("work", "Work") };
|
||||
it("builds a simple condition", () => {
|
||||
const f = buildFilter(parseQuery("invoice"), mailboxes, "inbox");
|
||||
expect(f).toEqual({ text: "invoice", inMailbox: "inbox" });
|
||||
});
|
||||
it("resolves in: to a mailbox by name and ANDs keyword conditions", () => {
|
||||
const f = buildFilter(parseQuery("in:work is:starred label:foo"), mailboxes, "inbox");
|
||||
expect(f).toEqual({ operator: "AND", conditions: [{ inMailbox: "work" }, { hasKeyword: "$flagged" }, { hasKeyword: "foo" }] });
|
||||
});
|
||||
it("maps is:unread to notKeyword $seen", () => {
|
||||
const f = buildFilter(parseQuery("is:unread"), mailboxes, null);
|
||||
expect(f).toEqual({ notKeyword: "$seen" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString } from "../sieve";
|
||||
|
||||
describe("sieve codec", () => {
|
||||
it("escapes strings", () => {
|
||||
expect(sieveString('a "quoted" \\ value')).toBe('"a \\"quoted\\" \\\\ value"');
|
||||
});
|
||||
it("generates tests", () => {
|
||||
expect(testToSieve({ type: "header", header: "subject", op: "contains", value: "hi" })).toBe('header :contains "subject" "hi"');
|
||||
expect(testToSieve({ type: "header", header: "x-foo", op: "notexists", value: "" })).toBe('not exists "x-foo"');
|
||||
expect(testToSieve({ type: "address", header: "from", part: "domain", op: "is", value: "example.com" })).toBe('address :domain :is "from" "example.com"');
|
||||
expect(testToSieve({ type: "size", op: "over", value: 2048 })).toBe("size :over 2048");
|
||||
});
|
||||
it("round-trips rules through a script", () => {
|
||||
const rules = [
|
||||
newRule({ id: "r1", name: "Newsletters", tests: [{ type: "header", header: "list-id", op: "exists", value: "" }], actions: [{ type: "fileinto", mailbox: "Newsletters" }, { type: "markread" }, { type: "stop" }] }),
|
||||
newRule({ id: "r2", name: "Big", enabled: false, join: "anyof", tests: [{ type: "size", op: "over", value: 5_000_000 }], actions: [{ type: "addflag", flag: "big" }] }),
|
||||
];
|
||||
const script = rulesToSieve(rules);
|
||||
expect(script).toContain('require ["fileinto", "imap4flags"];');
|
||||
expect(script).toContain('if exists "list-id"');
|
||||
expect(script).toContain('fileinto "Newsletters";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain("# (disabled) Big");
|
||||
expect(sieveToRules(script)).toEqual(rules);
|
||||
});
|
||||
it("reports hand-written scripts as raw", () => {
|
||||
expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull();
|
||||
expect(sieveToRules("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateRule, evaluateTest } from "../sieveApply";
|
||||
import type { Email } from "@/jmap/types";
|
||||
import type { SieveRule } from "../sieve";
|
||||
|
||||
const email = {
|
||||
id: "e1", blobId: "b", threadId: "t", mailboxIds: { inbox: true }, keywords: {}, size: 5000, receivedAt: "2026-01-01T00:00:00Z",
|
||||
from: [{ name: "Ada Lovelace", email: "[email protected]" }], to: [{ name: null, email: "[email protected]" }], subject: "Invoice #42 is ready", preview: "Please find attached",
|
||||
"header:List-Id:asText": "<dev.lists.example.org>",
|
||||
} as unknown as Email;
|
||||
|
||||
describe("sieve client-side evaluation", () => {
|
||||
it("evaluates header/address/size/body tests", () => {
|
||||
expect(evaluateTest(email, { type: "header", header: "from", op: "contains", value: "ada@" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "matches", value: "invoice*ready" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "subject", op: "regex", value: "^Invoice #\\d+" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "list-id", op: "exists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "header", header: "x-none", op: "notexists", value: "" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "domain", op: "is", value: "example.org" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "address", header: "from", part: "localpart", op: "is", value: "ada" })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "over", value: 1000 })).toBe(true);
|
||||
expect(evaluateTest(email, { type: "size", op: "under", value: 1000 })).toBe(false);
|
||||
expect(evaluateTest(email, { type: "body", op: "contains", value: "attached" }, "Please find attached the file")).toBe(true);
|
||||
});
|
||||
it("combines with allof/anyof", () => {
|
||||
const base: SieveRule = { id: "r", name: "r", enabled: true, join: "allof", tests: [{ type: "header", header: "from", op: "contains", value: "ada" }, { type: "header", header: "subject", op: "contains", value: "nope" }], actions: [] };
|
||||
expect(evaluateRule(email, base)).toBe(false);
|
||||
expect(evaluateRule(email, { ...base, join: "anyof" })).toBe(true);
|
||||
expect(evaluateRule(email, { ...base, tests: [{ type: "true" }] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMarkerSignature, compactHtml, markerOf, SIGNATURE_LIMIT } from "../signatureHtml";
|
||||
|
||||
describe("signature compaction", () => {
|
||||
it("strips office cruft and non-essential styles but keeps colours and links", () => {
|
||||
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Ellis</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const out = compactHtml(src);
|
||||
expect(out).not.toContain("mso-");
|
||||
expect(out).not.toContain("class=");
|
||||
expect(out).not.toContain("<xml");
|
||||
expect(out).not.toContain("o:p");
|
||||
expect(out).toContain("color:#1F4E79");
|
||||
expect(out).toContain("<b>John Ellis</b>");
|
||||
expect(out).toContain('href="https://linuxexpert.org"');
|
||||
expect(out).toContain('width="100"');
|
||||
expect(out.length).toBeLessThan(src.length / 2);
|
||||
});
|
||||
it("builds marker signatures within the limit", () => {
|
||||
const big = `<div>${"<b>x</b>".repeat(1000)}</div>`;
|
||||
const m = buildMarkerSignature("blob123", big);
|
||||
expect(m.htmlSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT);
|
||||
expect(m.textSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT);
|
||||
expect(markerOf(m.htmlSignature)).toEqual({ blobId: "blob123", type: "text/html" });
|
||||
expect(markerOf("<div>plain</div>")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlToText, quoteText, replySubject, textToHtml } from "../text";
|
||||
|
||||
describe("text helpers", () => {
|
||||
it("linkifies and escapes", () => {
|
||||
const html = textToHtml("see <https://x.io/a?b=1> now");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain('<a href="https://x.io/a?b=1"');
|
||||
});
|
||||
it("colors quote levels", () => {
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q1"');
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q2"');
|
||||
});
|
||||
it("converts html to text", () => {
|
||||
const t = htmlToText("<p>Hello <b>world</b></p><ul><li>one</li><li>two</li></ul><blockquote>q</blockquote><a href='https://a.b'>link</a>");
|
||||
expect(t).toContain("Hello world");
|
||||
expect(t).toContain("- one");
|
||||
expect(t).toContain("> q");
|
||||
expect(t).toContain("link <https://a.b>");
|
||||
});
|
||||
it("quotes and subjects", () => {
|
||||
expect(quoteText("a\n> b")).toBe("> a\n>> b");
|
||||
expect(replySubject("Re: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Fwd: Hi", "Re")).toBe("Re: Hi");
|
||||
expect(replySubject("Hi", "Fwd")).toBe("Fwd: Hi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
|
||||
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
|
||||
|
||||
export function isValidEmail(s: string): boolean {
|
||||
return EMAIL_RE.test(s.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a free-form recipient string ("Ann <[email protected]>, [email protected]; \"C, D\" <c@z>")
|
||||
* into a list of EmailAddress. Lenient by design.
|
||||
*/
|
||||
export function parseAddressList(input: string): EmailAddress[] {
|
||||
const out: EmailAddress[] = [];
|
||||
let buf = "";
|
||||
let inQuote = false;
|
||||
let inAngle = false;
|
||||
const flush = () => {
|
||||
const a = parseOne(buf);
|
||||
if (a) out.push(a);
|
||||
buf = "";
|
||||
};
|
||||
for (const ch of input) {
|
||||
if (ch === '"' && !inAngle) inQuote = !inQuote;
|
||||
if (ch === "<" && !inQuote) inAngle = true;
|
||||
if (ch === ">" && !inQuote) inAngle = false;
|
||||
if ((ch === "," || ch === ";" || ch === "\n") && !inQuote && !inAngle) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseOne(raw: string): EmailAddress | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
const m = /^(.*?)\s*<([^<>]+)>\s*$/.exec(s);
|
||||
if (m) {
|
||||
let name = m[1]!.trim();
|
||||
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, "$1");
|
||||
return { name: name || null, email: m[2]!.trim() };
|
||||
}
|
||||
return { name: null, email: s.replace(/^<|>$/g, "") };
|
||||
}
|
||||
|
||||
export function formatAddress(a: EmailAddress | null | undefined): string {
|
||||
if (!a) return "";
|
||||
if (!a.name) return a.email;
|
||||
const needsQuote = /[,;<>"()\\]/.test(a.name);
|
||||
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
|
||||
return `${name} <${a.email}>`;
|
||||
}
|
||||
|
||||
export function formatAddressList(list: EmailAddress[] | null | undefined): string {
|
||||
return (list ?? []).map(formatAddress).join(", ");
|
||||
}
|
||||
|
||||
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
|
||||
if (!a) return fallback;
|
||||
if (a.name?.trim()) return a.name.trim();
|
||||
return a.email || fallback;
|
||||
}
|
||||
|
||||
export function shortName(a: EmailAddress | null | undefined): string {
|
||||
const n = displayName(a, "");
|
||||
if (!n) return "";
|
||||
if (n.includes("@")) return n.split("@")[0]!;
|
||||
return n.split(/\s+/)[0]!;
|
||||
}
|
||||
|
||||
export function initials(a: EmailAddress | { name?: string | null; email?: string } | string | null | undefined): string {
|
||||
const name = typeof a === "string" ? a : a?.name || a?.email || "";
|
||||
const parts = name
|
||||
.replace(/[<>"]/g, "")
|
||||
.split(/[\s._@-]+/)
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
const PALETTE = [
|
||||
"#0f766e", "#b45309", "#7c3aed", "#be185d", "#1d4ed8", "#047857",
|
||||
"#c2410c", "#4338ca", "#a21caf", "#0e7490", "#b91c1c", "#15803d",
|
||||
];
|
||||
|
||||
export function avatarColor(seed: string | null | undefined): string {
|
||||
const s = (seed ?? "").toLowerCase();
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return PALETTE[h % PALETTE.length]!;
|
||||
}
|
||||
|
||||
export function sameAddress(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
return (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function uniqueAddresses(list: EmailAddress[]): EmailAddress[] {
|
||||
const seen = new Set<string>();
|
||||
const out: EmailAddress[] = [];
|
||||
for (const a of list) {
|
||||
const k = a.email.trim().toLowerCase();
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function domainOf(email: string): string {
|
||||
const i = email.lastIndexOf("@");
|
||||
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
|
||||
|
||||
/** Best display name for a card. */
|
||||
export function contactDisplayName(c: ContactCard): string {
|
||||
const n = c.name;
|
||||
if (n?.full?.trim()) return n.full.trim();
|
||||
const comps = n?.components ?? [];
|
||||
const ordered = comps.filter((x) => ["given", "given2", "surname", "surname2"].includes(x.kind));
|
||||
if (ordered.length) {
|
||||
// Prefer given + surname order regardless of isOrdered for display.
|
||||
const given = comps.filter((x) => x.kind === "given" || x.kind === "given2").map((x) => x.value).join(" ");
|
||||
const sur = comps.filter((x) => x.kind === "surname" || x.kind === "surname2").map((x) => x.value).join(" ");
|
||||
const s = `${given} ${sur}`.trim();
|
||||
if (s) return s;
|
||||
}
|
||||
if (c.kind === "group" || c.kind === "org") {
|
||||
const org = Object.values(c.organizations ?? {})[0]?.name;
|
||||
if (org) return org;
|
||||
}
|
||||
const nick = Object.values(c.nicknames ?? {})[0]?.name;
|
||||
if (nick) return nick;
|
||||
const org = Object.values(c.organizations ?? {})[0]?.name;
|
||||
if (org) return org;
|
||||
const email = primaryEmail(c);
|
||||
if (email) return email;
|
||||
return "(no name)";
|
||||
}
|
||||
|
||||
export function nameParts(c: ContactCard): { given: string; surname: string; prefix: string; suffix: string; middle: string } {
|
||||
const comps = c.name?.components ?? [];
|
||||
const pick = (k: string) => comps.filter((x) => x.kind === k).map((x) => x.value).join(" ");
|
||||
return { given: pick("given"), middle: pick("given2"), surname: pick("surname"), prefix: pick("title"), suffix: pick("credential") || pick("generation") };
|
||||
}
|
||||
|
||||
export function buildName(parts: { given?: string; middle?: string; surname?: string; prefix?: string; suffix?: string }): JSContactName | undefined {
|
||||
const components: JSContactName["components"] = [];
|
||||
if (parts.prefix?.trim()) components.push({ "@type": "NameComponent", kind: "title", value: parts.prefix.trim() });
|
||||
if (parts.given?.trim()) components.push({ "@type": "NameComponent", kind: "given", value: parts.given.trim() });
|
||||
if (parts.middle?.trim()) components.push({ "@type": "NameComponent", kind: "given2", value: parts.middle.trim() });
|
||||
if (parts.surname?.trim()) components.push({ "@type": "NameComponent", kind: "surname", value: parts.surname.trim() });
|
||||
if (parts.suffix?.trim()) components.push({ "@type": "NameComponent", kind: "credential", value: parts.suffix.trim() });
|
||||
if (!components.length) return undefined;
|
||||
const full = [parts.prefix, parts.given, parts.middle, parts.surname, parts.suffix].map((s) => s?.trim()).filter(Boolean).join(" ");
|
||||
return { "@type": "Name", components, isOrdered: true, full };
|
||||
}
|
||||
|
||||
export function primaryEmail(c: ContactCard): string | null {
|
||||
const emails = Object.values(c.emails ?? {});
|
||||
if (!emails.length) return null;
|
||||
const sorted = [...emails].sort((a, b) => (a.pref ?? 100) - (b.pref ?? 100));
|
||||
return sorted[0]!.address;
|
||||
}
|
||||
|
||||
export function contactEmails(c: ContactCard): EmailAddress[] {
|
||||
const name = contactDisplayName(c);
|
||||
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
|
||||
}
|
||||
|
||||
export function contactPhoto(c: ContactCard, accountId: string): string | null {
|
||||
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
|
||||
if (!m) return null;
|
||||
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
|
||||
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sortKey(c: ContactCard, by: "surname" | "given" = "given"): string {
|
||||
const p = nameParts(c);
|
||||
const k = by === "surname" ? `${p.surname} ${p.given}` : `${p.given} ${p.surname}`;
|
||||
return (k.trim() || contactDisplayName(c)).toLowerCase();
|
||||
}
|
||||
|
||||
export function formatAddressLines(a: { components?: Array<{ kind: string; value: string }>; full?: string }): string[] {
|
||||
if (a.full) return a.full.split(/\n/);
|
||||
const get = (k: string) =>
|
||||
(a.components ?? [])
|
||||
.filter((c) => c.kind === k)
|
||||
.map((c) => c.value)
|
||||
.join(" ");
|
||||
const lines: string[] = [];
|
||||
const street = [get("number"), get("name"), get("apartment"), get("building"), get("floor"), get("room")].filter(Boolean).join(" ");
|
||||
const pobox = get("postOfficeBox");
|
||||
if (pobox) lines.push(pobox);
|
||||
if (street) lines.push(street);
|
||||
const city = [get("locality"), get("region")].filter(Boolean).join(", ");
|
||||
const cityLine = [city, get("postcode")].filter(Boolean).join(" ");
|
||||
if (cityLine) lines.push(cityLine);
|
||||
if (get("country")) lines.push(get("country"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Generate a vCard 4.0 for export. */
|
||||
export function toVCard(c: ContactCard): string {
|
||||
const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
const lines = ["BEGIN:VCARD", "VERSION:4.0"];
|
||||
lines.push(`UID:${c.uid}`);
|
||||
if (c.kind && c.kind !== "individual") lines.push(`KIND:${c.kind}`);
|
||||
lines.push(`FN:${esc(contactDisplayName(c))}`);
|
||||
const p = nameParts(c);
|
||||
if (p.given || p.surname) lines.push(`N:${esc(p.surname)};${esc(p.given)};${esc(p.middle)};${esc(p.prefix)};${esc(p.suffix)}`);
|
||||
for (const n of Object.values(c.nicknames ?? {})) lines.push(`NICKNAME:${esc(n.name)}`);
|
||||
for (const e of Object.values(c.emails ?? {})) {
|
||||
const types = Object.keys(e.contexts ?? {}).join(",");
|
||||
lines.push(`EMAIL${types ? `;TYPE=${types}` : ""}${e.pref ? `;PREF=${e.pref}` : ""}:${e.address}`);
|
||||
}
|
||||
for (const ph of Object.values(c.phones ?? {})) {
|
||||
const types = [...Object.keys(ph.contexts ?? {}), ...Object.keys(ph.features ?? {})].join(",");
|
||||
lines.push(`TEL${types ? `;TYPE=${types}` : ""}${ph.pref ? `;PREF=${ph.pref}` : ""}:${ph.number}`);
|
||||
}
|
||||
for (const a of Object.values(c.addresses ?? {})) {
|
||||
const get = (k: string) =>
|
||||
(a.components ?? [])
|
||||
.filter((x) => x.kind === k)
|
||||
.map((x) => x.value)
|
||||
.join(" ");
|
||||
const street = [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ");
|
||||
const types = Object.keys(a.contexts ?? {}).join(",");
|
||||
lines.push(`ADR${types ? `;TYPE=${types}` : ""}:${esc(get("postOfficeBox"))};;${esc(street)};${esc(get("locality"))};${esc(get("region"))};${esc(get("postcode"))};${esc(get("country"))}`);
|
||||
}
|
||||
for (const o of Object.values(c.organizations ?? {})) lines.push(`ORG:${esc(o.name ?? "")}${(o.units ?? []).map((u) => `;${esc(u.name)}`).join("")}`);
|
||||
for (const t of Object.values(c.titles ?? {})) lines.push(`${t.kind === "role" ? "ROLE" : "TITLE"}:${esc(t.name)}`);
|
||||
for (const an of Object.values(c.anniversaries ?? {})) {
|
||||
const d = an.date;
|
||||
const v = d.utc ? d.utc.slice(0, 10).replace(/-/g, "") : `${d.year ?? "--"}${String(d.month ?? 0).padStart(2, "0")}${String(d.day ?? 0).padStart(2, "0")}`;
|
||||
if (an.kind === "birth") lines.push(`BDAY:${v}`);
|
||||
else if (an.kind === "wedding") lines.push(`ANNIVERSARY:${v}`);
|
||||
}
|
||||
for (const n of Object.values(c.notes ?? {})) lines.push(`NOTE:${esc(n.note)}`);
|
||||
for (const l of Object.values(c.links ?? {})) lines.push(`URL:${l.uri}`);
|
||||
for (const s of Object.values(c.onlineServices ?? {})) if (s.uri) lines.push(`IMPP:${s.uri}`);
|
||||
if (c.members) for (const m of Object.keys(c.members)) lines.push(`MEMBER:${m}`);
|
||||
lines.push("END:VCARD");
|
||||
return lines.map(fold).join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
function fold(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
out.push((i ? " " : "") + line.slice(i, i + 74));
|
||||
i += 74;
|
||||
}
|
||||
return out.join("\r\n");
|
||||
}
|
||||
|
||||
export function newKey(prefix = "k"): string {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
export const DAY_MS = 86_400_000;
|
||||
|
||||
export function startOfDay(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function endOfDay(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function addMonths(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
const day = x.getDate();
|
||||
x.setDate(1);
|
||||
x.setMonth(x.getMonth() + n);
|
||||
const dim = daysInMonth(x.getFullYear(), x.getMonth());
|
||||
x.setDate(Math.min(day, dim));
|
||||
return x;
|
||||
}
|
||||
|
||||
export function addMinutes(d: Date, n: number): Date {
|
||||
return new Date(d.getTime() + n * 60_000);
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
export function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
export function endOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
/** weekStart: 0 = Sunday, 1 = Monday */
|
||||
export function startOfWeek(d: Date, weekStart = 1): Date {
|
||||
const x = startOfDay(d);
|
||||
const diff = (x.getDay() - weekStart + 7) % 7;
|
||||
return addDays(x, -diff);
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function isToday(d: Date): boolean {
|
||||
return isSameDay(d, new Date());
|
||||
}
|
||||
|
||||
/** 6x7 grid of dates covering the month view. */
|
||||
export function monthGrid(anchor: Date, weekStart = 1): Date[] {
|
||||
const first = startOfWeek(startOfMonth(anchor), weekStart);
|
||||
const out: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) out.push(addDays(first, i));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function weekDays(anchor: Date, weekStart = 1, count = 7): Date[] {
|
||||
const first = startOfWeek(anchor, weekStart);
|
||||
const out: Date[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(addDays(first, i));
|
||||
return out;
|
||||
}
|
||||
|
||||
function pad(n: number, w = 2): string {
|
||||
return String(n).padStart(w, "0");
|
||||
}
|
||||
|
||||
/** Format a Date's wall-clock (browser local) as JSCalendar LocalDateTime. */
|
||||
export function toLocalDateTime(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function toLocalDateOnly(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Date → "YYYY-MM-DDTHH:MM:SSZ" (JMAP UTCDate, no millis). */
|
||||
export function toUTCDate(d: Date): string {
|
||||
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export function parseLocalDateTime(s: string): { y: number; mo: number; d: number; h: number; mi: number; se: number } | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s);
|
||||
if (!m) return null;
|
||||
return { y: +m[1]!, mo: +m[2]! - 1, d: +m[3]!, h: +(m[4] ?? 0), mi: +(m[5] ?? 0), se: +(m[6] ?? 0) };
|
||||
}
|
||||
|
||||
const dtfCache = new Map<string, Intl.DateTimeFormat>();
|
||||
function dtf(tz: string): Intl.DateTimeFormat | null {
|
||||
let f = dtfCache.get(tz);
|
||||
if (f) return f;
|
||||
try {
|
||||
f = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
dtfCache.set(tz, f);
|
||||
return f;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset (ms) of timezone `tz` at instant `date`. */
|
||||
export function tzOffsetMs(date: Date, tz: string): number {
|
||||
const f = dtf(tz);
|
||||
if (!f) return -date.getTimezoneOffset() * 60_000;
|
||||
const parts = f.formatToParts(date);
|
||||
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
|
||||
const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
|
||||
return asUTC - Math.floor(date.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** Interpret a JSCalendar LocalDateTime in timezone `tz` (or browser local if null) as an instant. */
|
||||
export function zonedToDate(local: string, tz: string | null | undefined): Date {
|
||||
const p = parseLocalDateTime(local);
|
||||
if (!p) return new Date(NaN);
|
||||
if (!tz) {
|
||||
return new Date(p.y, p.mo, p.d, p.h, p.mi, p.se);
|
||||
}
|
||||
const asUTC = Date.UTC(p.y, p.mo, p.d, p.h, p.mi, p.se);
|
||||
// Two-pass offset resolution handles DST edges reasonably.
|
||||
let off = tzOffsetMs(new Date(asUTC), tz);
|
||||
off = tzOffsetMs(new Date(asUTC - off), tz);
|
||||
return new Date(asUTC - off);
|
||||
}
|
||||
|
||||
/** Format an instant as LocalDateTime in timezone `tz` (browser local if null). */
|
||||
export function dateToZonedLocal(d: Date, tz: string | null | undefined): string {
|
||||
if (!tz) return toLocalDateTime(d);
|
||||
const f = dtf(tz);
|
||||
if (!f) return toLocalDateTime(d);
|
||||
const parts = f.formatToParts(d);
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
|
||||
return `${get("year")}-${get("month")}-${get("day")}T${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`;
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 duration (e.g. "P1DT2H30M") into seconds. */
|
||||
export function parseDuration(dur: string | null | undefined): number {
|
||||
if (!dur) return 0;
|
||||
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(dur);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
const w = Number(m[2] ?? 0), d = Number(m[3] ?? 0), h = Number(m[4] ?? 0), mi = Number(m[5] ?? 0), s = Number(m[6] ?? 0);
|
||||
return sign * (w * 7 * 86400 + d * 86400 + h * 3600 + mi * 60 + s);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const neg = seconds < 0;
|
||||
let s = Math.abs(Math.round(seconds));
|
||||
const d = Math.floor(s / 86400);
|
||||
s -= d * 86400;
|
||||
const h = Math.floor(s / 3600);
|
||||
s -= h * 3600;
|
||||
const m = Math.floor(s / 60);
|
||||
s -= m * 60;
|
||||
let out = "P";
|
||||
if (d) out += `${d}D`;
|
||||
if (h || m || s) {
|
||||
out += "T";
|
||||
if (h) out += `${h}H`;
|
||||
if (m) out += `${m}M`;
|
||||
if (s) out += `${s}S`;
|
||||
}
|
||||
if (out === "P") out = "PT0S";
|
||||
return (neg ? "-" : "") + out;
|
||||
}
|
||||
|
||||
export function humanDuration(seconds: number): string {
|
||||
const abs = Math.abs(seconds);
|
||||
if (abs === 0) return "at time of event";
|
||||
const parts: string[] = [];
|
||||
const d = Math.floor(abs / 86400);
|
||||
const h = Math.floor((abs % 86400) / 3600);
|
||||
const m = Math.floor((abs % 3600) / 60);
|
||||
if (d) parts.push(`${d} day${d === 1 ? "" : "s"}`);
|
||||
if (h) parts.push(`${h} hour${h === 1 ? "" : "s"}`);
|
||||
if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`);
|
||||
return parts.join(" ") || `${abs} seconds`;
|
||||
}
|
||||
|
||||
export const browserTimeZone = (() => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
})();
|
||||
|
||||
export function listTimeZones(): string[] {
|
||||
try {
|
||||
const sv = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
|
||||
if (sv) return sv("timeZone");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ["UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney"];
|
||||
}
|
||||
|
||||
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
|
||||
if (allDay) {
|
||||
const lastDay = new Date(end.getTime() - 1);
|
||||
if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
||||
return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
}
|
||||
const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
if (isSameDay(start, end)) {
|
||||
return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} – ${t(end)}`;
|
||||
}
|
||||
return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} – ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
|
||||
/** For <input type="datetime-local"> */
|
||||
export function toInputDateTime(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fromInputDateTime(s: string): Date {
|
||||
const p = parseLocalDateTime(s);
|
||||
if (!p) return new Date(NaN);
|
||||
return new Date(p.y, p.mo, p.d, p.h, p.mi, 0);
|
||||
}
|
||||
|
||||
export function roundToNext(d: Date, minutes: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setSeconds(0, 0);
|
||||
const m = x.getMinutes();
|
||||
const r = Math.ceil(m / minutes) * minutes;
|
||||
x.setMinutes(r);
|
||||
return x;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
const rtf = typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
|
||||
|
||||
export function formatSize(bytes: number | null | undefined): string {
|
||||
if (bytes == null || !Number.isFinite(bytes)) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = bytes / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
/** Gmail-style compact date for list views. */
|
||||
export function formatListDate(iso: string | null | undefined, now = new Date()): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (isSameDay(d, now)) return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" */
|
||||
export function formatFullDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const diff = (d.getTime() - now.getTime()) / 1000;
|
||||
const abs = Math.abs(diff);
|
||||
if (!rtf) return formatListDate(iso, now);
|
||||
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
||||
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
||||
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
||||
if (abs < 86400 * 7) return rtf.format(Math.round(diff / 86400), "day");
|
||||
return formatListDate(iso, now);
|
||||
}
|
||||
|
||||
export function formatDateShort(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function formatTime(d: Date): string {
|
||||
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatMonthYear(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export function plural(n: number, one: string, many = `${one}s`): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
export function truncate(s: string, n: number): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
export function uid(prefix = "u"): string {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: never[]) => void>(fn: T, ms: number): T & { cancel(): void } {
|
||||
let t: number | null = null;
|
||||
const wrapped = ((...args: Parameters<T>) => {
|
||||
if (t) window.clearTimeout(t);
|
||||
t = window.setTimeout(() => {
|
||||
t = null;
|
||||
fn(...args);
|
||||
}, ms);
|
||||
}) as T & { cancel(): void };
|
||||
wrapped.cancel = () => {
|
||||
if (t) window.clearTimeout(t);
|
||||
t = null;
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
cidMap?: Record<string, string>;
|
||||
/** Whether remote content (http/https images, css urls) may load. */
|
||||
allowRemote?: boolean;
|
||||
/** Route remote images through the privacy proxy. */
|
||||
proxyRemote?: boolean;
|
||||
}
|
||||
|
||||
export interface SanitizeResult {
|
||||
html: string;
|
||||
remoteCount: number;
|
||||
bodyStyle: string;
|
||||
}
|
||||
|
||||
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
||||
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
||||
|
||||
let hooked = false;
|
||||
function ensureHooks() {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
|
||||
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
|
||||
if (data.tagName === "style" && node.textContent) {
|
||||
// Remove @import and remote url() references; they're handled later in processRemote().
|
||||
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
|
||||
}
|
||||
});
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
// Forms are forbidden but be safe about formaction-like attributes on anything.
|
||||
for (const attr of ["formaction", "action", "ping", "xlink:href"]) {
|
||||
if (node.hasAttribute(attr)) node.removeAttribute(attr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return `/api/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
ensureHooks();
|
||||
let bodyStyle = "";
|
||||
const bodyMatch = /<body([^>]*)>/i.exec(input);
|
||||
if (bodyMatch) {
|
||||
const attrs = bodyMatch[1]!;
|
||||
const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1];
|
||||
const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1];
|
||||
if (bg) bodyStyle += `background-color:${bg.trim()};`;
|
||||
if (style) bodyStyle += style;
|
||||
}
|
||||
|
||||
const clean = DOMPurify.sanitize(input, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
RETURN_DOM: true,
|
||||
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ["style", "center", "font", "marquee"],
|
||||
ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"],
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
let remoteCount = 0;
|
||||
const cidMap = opts.cidMap ?? {};
|
||||
const allow = Boolean(opts.allowRemote);
|
||||
const proxy = Boolean(opts.proxyRemote);
|
||||
|
||||
const remote = (url: string): string => {
|
||||
remoteCount++;
|
||||
if (!allow) return "";
|
||||
return proxy ? proxiedImageUrl(url) : url;
|
||||
};
|
||||
|
||||
const rewriteUrl = (raw: string): { url: string; keep: boolean } => {
|
||||
const url = raw.trim();
|
||||
if (/^cid:/i.test(url)) {
|
||||
const cid = url.slice(4).replace(/^<|>$/g, "");
|
||||
const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()];
|
||||
return mapped ? { url: mapped, keep: true } : { url: "", keep: false };
|
||||
}
|
||||
if (/^data:image\//i.test(url)) return { url, keep: true };
|
||||
if (REMOTE_URL_RE.test(url)) {
|
||||
const abs = url.startsWith("//") ? `https:${url}` : url;
|
||||
const u = remote(abs);
|
||||
return { url: u, keep: Boolean(u) };
|
||||
}
|
||||
// Relative or unknown scheme -> drop.
|
||||
return { url: "", keep: false };
|
||||
};
|
||||
|
||||
// Image-bearing attributes
|
||||
const els = clean.querySelectorAll<HTMLElement>("[src],[background],[poster],[srcset]");
|
||||
els.forEach((el) => {
|
||||
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
||||
for (const attr of ["src", "background", "poster"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v == null) continue;
|
||||
const r = rewriteUrl(v);
|
||||
if (r.keep) el.setAttribute(attr, r.url);
|
||||
else {
|
||||
el.removeAttribute(attr);
|
||||
if (attr === "src" && el.tagName === "IMG") {
|
||||
el.setAttribute("data-ihm-blocked", "1");
|
||||
if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CSS url() in style attributes and <style> blocks
|
||||
const rewriteCss = (css: string): string =>
|
||||
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
|
||||
const r = rewriteUrl(u);
|
||||
return r.keep ? `url(${q}${r.url}${q})` : "none";
|
||||
});
|
||||
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||
const s = el.getAttribute("style");
|
||||
if (s && /url\(/i.test(s)) el.setAttribute("style", rewriteCss(s));
|
||||
});
|
||||
clean.querySelectorAll("style").forEach((st) => {
|
||||
if (st.textContent && /url\(|@import/i.test(st.textContent)) {
|
||||
st.textContent = rewriteCss(st.textContent.replace(/@import[^;]+;?/gi, ""));
|
||||
}
|
||||
});
|
||||
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
|
||||
|
||||
return { html: clean.innerHTML, remoteCount, bodyStyle };
|
||||
}
|
||||
|
||||
/** Minimal sanitizer for signatures / composer HTML (no remote blocking, keeps images). */
|
||||
export function sanitizeEditorHtml(input: string): string {
|
||||
ensureHooks();
|
||||
return DOMPurify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "meta", "link", "base", "svg", "math"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "ping", "onerror", "onload"],
|
||||
ADD_ATTR: ["target", "bgcolor", "align", "valign", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size"],
|
||||
}) as string;
|
||||
}
|
||||
|
||||
/** Base CSS injected into the shadow root that hosts HTML email. */
|
||||
export const EMAIL_BASE_CSS = `
|
||||
:host { display:block; color-scheme: light; }
|
||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
||||
.ihm-email-root img { max-width:100%; height:auto; }
|
||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||
.ihm-email-root table { max-width:100%; }
|
||||
.ihm-email-root pre { white-space:pre-wrap; }
|
||||
.ihm-email-root blockquote { margin:0 0 0 .8ex; border-left:2px solid #cbd5e1; padding-left:1ex; color:#475569; }
|
||||
.ihm-email-root a { color:#0f766e; }
|
||||
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
||||
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
||||
`;
|
||||
|
||||
export const TEXT_EMAIL_CSS = `
|
||||
:host { display:block; }
|
||||
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
||||
.ihm-text-root a { color: var(--link, #0f766e); }
|
||||
.ihm-text-root .q1 { color: var(--q1,#2563eb); } .ihm-text-root .q2 { color: var(--q2,#16a34a); } .ihm-text-root .q3 { color: var(--q3,#9333ea); }
|
||||
`;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Gmail-style keyboard shortcut manager with two-key sequences ("g i").
|
||||
* Handlers are registered in scopes; the most recently pushed scope wins.
|
||||
*/
|
||||
export type KeyHandler = (e: KeyboardEvent) => void | boolean;
|
||||
|
||||
interface Binding {
|
||||
keys: string; // e.g. "j", "shift+i", "g i", "mod+enter"
|
||||
handler: KeyHandler;
|
||||
description: string;
|
||||
group: string;
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
name: string;
|
||||
bindings: Binding[];
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
private scopes: Scope[] = [];
|
||||
private pendingPrefix: string | null = null;
|
||||
private prefixTimer: number | null = null;
|
||||
enabled = true;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") window.addEventListener("keydown", this.onKeyDown, true);
|
||||
}
|
||||
|
||||
pushScope(name: string, bindings: Binding[]): () => void {
|
||||
const scope = { name, bindings };
|
||||
this.scopes.push(scope);
|
||||
return () => {
|
||||
this.scopes = this.scopes.filter((s) => s !== scope);
|
||||
};
|
||||
}
|
||||
|
||||
/** All bindings with descriptions, for the help overlay. */
|
||||
list(): Array<{ group: string; keys: string; description: string }> {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ group: string; keys: string; description: string }> = [];
|
||||
for (const s of [...this.scopes].reverse()) {
|
||||
for (const b of s.bindings) {
|
||||
if (!b.description || seen.has(b.keys)) continue;
|
||||
seen.add(b.keys);
|
||||
out.push({ group: b.group, keys: b.keys, description: b.description });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.enabled) return;
|
||||
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
|
||||
if (document.querySelector(".dialog-backdrop, .popover")) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const inInput =
|
||||
!!target &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
|
||||
const combo = comboOf(e);
|
||||
if (!combo) return;
|
||||
|
||||
// Try sequence completion first.
|
||||
const candidates: Binding[] = [];
|
||||
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
||||
for (const b of this.scopes[i]!.bindings) candidates.push(b);
|
||||
}
|
||||
if (this.pendingPrefix) {
|
||||
const seq = `${this.pendingPrefix} ${combo}`;
|
||||
const b = candidates.find((x) => x.keys === seq && (!inInput || x.allowInInput));
|
||||
this.clearPrefix();
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Is this combo the first half of any sequence?
|
||||
if (!inInput && candidates.some((x) => x.keys.startsWith(`${combo} `))) {
|
||||
this.pendingPrefix = combo;
|
||||
this.prefixTimer = window.setTimeout(() => this.clearPrefix(), 1200);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const b = candidates.find((x) => x.keys === combo && (!inInput || x.allowInInput));
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private clearPrefix() {
|
||||
this.pendingPrefix = null;
|
||||
if (this.prefixTimer) window.clearTimeout(this.prefixTimer);
|
||||
this.prefixTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export function comboOf(e: KeyboardEvent): string | null {
|
||||
const key = e.key;
|
||||
if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") return null;
|
||||
const parts: string[] = [];
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (mod) parts.push("mod");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey && key.length > 1) parts.push("shift");
|
||||
let k = key;
|
||||
if (k === " ") k = "space";
|
||||
else if (k === "Escape") k = "esc";
|
||||
else if (k.length === 1) {
|
||||
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
|
||||
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
|
||||
} else k = k.toLowerCase();
|
||||
parts.push(k);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function formatKeys(keys: string): string {
|
||||
return keys
|
||||
.split(" ")
|
||||
.map((k) =>
|
||||
k
|
||||
.split("+")
|
||||
.map((p) => (p === "mod" ? (isMac ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "alt" ? (isMac ? "⌥" : "Alt") : p === "enter" ? "↵" : p === "esc" ? "Esc" : p === "space" ? "Space" : p === "arrowup" ? "↑" : p === "arrowdown" ? "↓" : p === "arrowleft" ? "←" : p === "arrowright" ? "→" : p.length === 1 ? p : p[0]!.toUpperCase() + p.slice(1)))
|
||||
.join(isMac ? "" : "+"),
|
||||
)
|
||||
.join(" then ");
|
||||
}
|
||||
|
||||
export const keyboard = new Keyboard();
|
||||
@@ -0,0 +1,95 @@
|
||||
let baseTitle = "ihasmail";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
let baseFavicon: HTMLImageElement | null = null;
|
||||
|
||||
export function setBaseTitle(t: string) {
|
||||
baseTitle = t;
|
||||
}
|
||||
|
||||
/** Update document title and favicon badge with unread count. */
|
||||
export function setUnreadBadge(count: number): void {
|
||||
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
|
||||
try {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
|
||||
if (!link) return;
|
||||
if (!baseFavicon) {
|
||||
baseFavicon = new Image();
|
||||
baseFavicon.src = "/img/favicon-64.png";
|
||||
baseFavicon.onload = () => setUnreadBadge(count);
|
||||
return;
|
||||
}
|
||||
if (!baseFavicon.complete) return;
|
||||
if (count <= 0) {
|
||||
link.href = "/img/favicon-64.png";
|
||||
return;
|
||||
}
|
||||
faviconCanvas ??= document.createElement("canvas");
|
||||
const c = faviconCanvas;
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, 64, 64);
|
||||
ctx.drawImage(baseFavicon, 0, 0, 64, 64);
|
||||
ctx.fillStyle = "#dc2626";
|
||||
ctx.beginPath();
|
||||
ctx.arc(46, 18, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "bold 22px system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(count > 99 ? "99" : String(count), 46, 19);
|
||||
link.href = c.toDataURL("image/png");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (!("Notification" in window)) return "denied";
|
||||
if (Notification.permission !== "default") return Notification.permission;
|
||||
try {
|
||||
return await Notification.requestPermission();
|
||||
} catch {
|
||||
return "denied";
|
||||
}
|
||||
}
|
||||
|
||||
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
||||
try {
|
||||
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
opts.onClick?.();
|
||||
n.close();
|
||||
};
|
||||
setTimeout(() => n.close(), 8000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
/** Short, soft "ding" using WebAudio (no asset needed). */
|
||||
export function playNewMailSound(): void {
|
||||
try {
|
||||
audioCtx ??= new AudioContext();
|
||||
const ctx = audioCtx;
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.type = "sine";
|
||||
o.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
o.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.08);
|
||||
g.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4);
|
||||
o.connect(g).connect(ctx.destination);
|
||||
o.start();
|
||||
o.stop(ctx.currentTime + 0.45);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
|
||||
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
|
||||
{ key: "mo", label: "Monday", short: "M" },
|
||||
{ key: "tu", label: "Tuesday", short: "T" },
|
||||
{ key: "we", label: "Wednesday", short: "W" },
|
||||
{ key: "th", label: "Thursday", short: "T" },
|
||||
{ key: "fr", label: "Friday", short: "F" },
|
||||
{ key: "sa", label: "Saturday", short: "S" },
|
||||
{ key: "su", label: "Sunday", short: "S" },
|
||||
];
|
||||
|
||||
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
|
||||
|
||||
export function presetFor(rule: JSCalendarRecurrenceRule | undefined): RecurrencePreset {
|
||||
if (!rule) return "none";
|
||||
const simple = !rule.count && !rule.until && (rule.interval ?? 1) === 1;
|
||||
if (rule.frequency === "daily" && simple && !rule.byDay) return "daily";
|
||||
if (rule.frequency === "weekly" && simple) {
|
||||
if (!rule.byDay) return "weekly";
|
||||
const days = rule.byDay.map((d) => d.day).sort().join(",");
|
||||
if (days === ["mo", "tu", "we", "th", "fr"].sort().join(",")) return "weekdays";
|
||||
if (rule.byDay.length === 1) return "weekly";
|
||||
}
|
||||
if (rule.frequency === "monthly" && simple && !rule.byDay && (!rule.byMonthDay || rule.byMonthDay.length === 1)) return "monthly";
|
||||
if (rule.frequency === "yearly" && simple && !rule.byDay && !rule.byMonth) return "yearly";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
|
||||
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
|
||||
switch (preset) {
|
||||
case "daily":
|
||||
return { "@type": "RecurrenceRule", frequency: "daily" };
|
||||
case "weekly":
|
||||
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: dow }] };
|
||||
case "weekdays":
|
||||
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: ["mo", "tu", "we", "th", "fr"].map((d) => ({ "@type": "NDay" as const, day: d as JSCalendarNDay["day"] })) };
|
||||
case "monthly":
|
||||
return { "@type": "RecurrenceRule", frequency: "monthly", byMonthDay: [start.getDate()] };
|
||||
case "yearly":
|
||||
return { "@type": "RecurrenceRule", frequency: "yearly" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
|
||||
if (!rule) return "Does not repeat";
|
||||
const n = rule.interval ?? 1;
|
||||
let base: string;
|
||||
switch (rule.frequency) {
|
||||
case "daily":
|
||||
base = n === 1 ? "Daily" : `Every ${n} days`;
|
||||
break;
|
||||
case "weekly": {
|
||||
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
|
||||
if (rule.byDay?.length) {
|
||||
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
|
||||
const set = rule.byDay.map((d) => d.day).sort().join(",");
|
||||
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
|
||||
else base += ` on ${names.join(", ")}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "monthly": {
|
||||
base = n === 1 ? "Monthly" : `Every ${n} months`;
|
||||
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
|
||||
else if (rule.byDay?.length) {
|
||||
const d = rule.byDay[0]!;
|
||||
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
|
||||
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "yearly":
|
||||
base = n === 1 ? "Yearly" : `Every ${n} years`;
|
||||
break;
|
||||
default:
|
||||
base = `Every ${n} ${rule.frequency}`;
|
||||
}
|
||||
if (rule.count) base += `, ${rule.count} times`;
|
||||
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const s = ["th", "st", "nd", "rd"];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { EmailFilter, EmailFilterCondition, Mailbox } from "@/jmap/types";
|
||||
|
||||
export interface ParsedQuery {
|
||||
text: string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
hasAttachment?: boolean;
|
||||
unread?: boolean;
|
||||
read?: boolean;
|
||||
starred?: boolean;
|
||||
in?: string;
|
||||
label?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
larger?: number;
|
||||
smaller?: number;
|
||||
notLabel?: string[];
|
||||
}
|
||||
|
||||
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/i;
|
||||
function parseSize(s: string): number | undefined {
|
||||
const m = SIZE_RE.exec(s.trim());
|
||||
if (!m) return undefined;
|
||||
const n = Number(m[1]);
|
||||
const unit = (m[2] ?? "").toLowerCase();
|
||||
const mult = unit.startsWith("k") ? 1024 : unit.startsWith("m") ? 1024 ** 2 : unit.startsWith("g") ? 1024 ** 3 : 1;
|
||||
return Math.round(n * mult);
|
||||
}
|
||||
|
||||
function parseDate(s: string, endOfDay = false): string | undefined {
|
||||
const t = s.trim();
|
||||
let d: Date | null = null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t) || /^\d{4}\/\d{2}\/\d{2}$/.test(t)) {
|
||||
const [y, m, dd] = t.split(/[-/]/).map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(t)) {
|
||||
const [m, dd, y] = t.split("/").map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else {
|
||||
const rel = /^(\d+)([dwmy])$/.exec(t);
|
||||
if (rel) {
|
||||
d = new Date();
|
||||
const n = Number(rel[1]);
|
||||
if (rel[2] === "d") d.setDate(d.getDate() - n);
|
||||
if (rel[2] === "w") d.setDate(d.getDate() - n * 7);
|
||||
if (rel[2] === "m") d.setMonth(d.getMonth() - n);
|
||||
if (rel[2] === "y") d.setFullYear(d.getFullYear() - n);
|
||||
} else {
|
||||
const p = new Date(t);
|
||||
if (!Number.isNaN(p.getTime())) d = p;
|
||||
}
|
||||
}
|
||||
if (!d || Number.isNaN(d.getTime())) return undefined;
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999);
|
||||
else d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Tokenize respecting quotes. */
|
||||
function tokenize(q: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /(\S+?:"[^"]*"|"[^"]*"|\S+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(q))) out.push(m[1]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseQuery(q: string): ParsedQuery {
|
||||
const p: ParsedQuery = { text: [] };
|
||||
for (const tok of tokenize(q)) {
|
||||
const idx = tok.indexOf(":");
|
||||
const key = idx > 0 ? tok.slice(0, idx).toLowerCase() : "";
|
||||
let val = idx > 0 ? tok.slice(idx + 1) : tok;
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
const neg = key.startsWith("-");
|
||||
const k = neg ? key.slice(1) : key;
|
||||
switch (k) {
|
||||
case "from":
|
||||
p.from = val;
|
||||
break;
|
||||
case "to":
|
||||
p.to = val;
|
||||
break;
|
||||
case "cc":
|
||||
p.cc = val;
|
||||
break;
|
||||
case "subject":
|
||||
p.subject = val;
|
||||
break;
|
||||
case "body":
|
||||
p.body = val;
|
||||
break;
|
||||
case "has":
|
||||
if (val === "attachment") p.hasAttachment = true;
|
||||
if (val === "star" || val === "flag") p.starred = true;
|
||||
break;
|
||||
case "is":
|
||||
if (val === "unread") p.unread = true;
|
||||
if (val === "read") p.read = true;
|
||||
if (val === "starred" || val === "flagged") p.starred = true;
|
||||
break;
|
||||
case "in":
|
||||
case "folder":
|
||||
p.in = val;
|
||||
break;
|
||||
case "label":
|
||||
case "keyword":
|
||||
if (neg) (p.notLabel ??= []).push(val);
|
||||
else (p.label ??= []).push(val);
|
||||
break;
|
||||
case "before":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "after":
|
||||
case "since":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "newer":
|
||||
case "newer_than":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "older":
|
||||
case "older_than":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "larger":
|
||||
case "size":
|
||||
p.larger = parseSize(val);
|
||||
break;
|
||||
case "smaller":
|
||||
p.smaller = parseSize(val);
|
||||
break;
|
||||
default:
|
||||
p.text.push(val);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function buildFilter(p: ParsedQuery, mailboxes: Record<string, Mailbox>, currentMailbox?: string | null): EmailFilter {
|
||||
const conds: EmailFilterCondition[] = [];
|
||||
const c: EmailFilterCondition = {};
|
||||
if (p.text.length) c.text = p.text.join(" ");
|
||||
if (p.from) c.from = p.from;
|
||||
if (p.to) c.to = p.to;
|
||||
if (p.cc) c.cc = p.cc;
|
||||
if (p.subject) c.subject = p.subject;
|
||||
if (p.body) c.body = p.body;
|
||||
if (p.hasAttachment) c.hasAttachment = true;
|
||||
if (p.unread) c.notKeyword = "$seen";
|
||||
if (p.read) c.hasKeyword = "$seen";
|
||||
if (p.before) c.before = p.before;
|
||||
if (p.after) c.after = p.after;
|
||||
if (p.larger != null) c.minSize = p.larger;
|
||||
if (p.smaller != null) c.maxSize = p.smaller;
|
||||
if (p.in) {
|
||||
const mb = resolveMailbox(p.in, mailboxes);
|
||||
if (mb) c.inMailbox = mb.id;
|
||||
} else if (currentMailbox) {
|
||||
c.inMailbox = currentMailbox;
|
||||
}
|
||||
conds.push(c);
|
||||
if (p.starred) conds.push({ hasKeyword: "$flagged" });
|
||||
for (const l of p.label ?? []) conds.push({ hasKeyword: l.startsWith("$") ? l : l });
|
||||
for (const l of p.notLabel ?? []) conds.push({ notKeyword: l });
|
||||
if (conds.length === 1) return conds[0]!;
|
||||
return { operator: "AND", conditions: conds };
|
||||
}
|
||||
|
||||
export function resolveMailbox(name: string, mailboxes: Record<string, Mailbox>): Mailbox | undefined {
|
||||
const n = name.toLowerCase();
|
||||
const list = Object.values(mailboxes);
|
||||
const byRole = list.find((m) => m.role === n || (n === "spam" && m.role === "junk") || (n === "starred" && m.role === "flagged") || (n === "anywhere" && m.role === "all"));
|
||||
if (byRole) return byRole;
|
||||
if (n === "anywhere" || n === "all") return undefined;
|
||||
return list.find((m) => m.name.toLowerCase() === n) ?? list.find((m) => m.name.toLowerCase().includes(n));
|
||||
}
|
||||
|
||||
export function describeFilter(p: ParsedQuery): string {
|
||||
const parts: string[] = [];
|
||||
if (p.text.length) parts.push(`"${p.text.join(" ")}"`);
|
||||
if (p.from) parts.push(`from ${p.from}`);
|
||||
if (p.to) parts.push(`to ${p.to}`);
|
||||
if (p.subject) parts.push(`subject ${p.subject}`);
|
||||
if (p.hasAttachment) parts.push("has attachment");
|
||||
if (p.unread) parts.push("unread");
|
||||
if (p.starred) parts.push("starred");
|
||||
if (p.in) parts.push(`in ${p.in}`);
|
||||
return parts.join(", ") || "all mail";
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Visual filter rules <-> Sieve script codec.
|
||||
*
|
||||
* Rules are persisted inside the Sieve script itself as JSON comments
|
||||
* (`# rule:{...}`) so the UI can round-trip them losslessly; the generated
|
||||
* Sieve below each comment is what the server actually runs.
|
||||
*/
|
||||
|
||||
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
||||
|
||||
export type SieveTest =
|
||||
| { type: "header"; header: string; op: HeaderOp; value: string }
|
||||
| { type: "address"; header: string; part: "all" | "localpart" | "domain"; op: HeaderOp; value: string }
|
||||
| { type: "size"; op: "over" | "under"; value: number }
|
||||
| { type: "body"; op: "contains" | "notcontains"; value: string }
|
||||
| { type: "true" };
|
||||
|
||||
export type SieveAction =
|
||||
| { type: "fileinto"; mailbox: string; mailboxId?: string; copy?: boolean }
|
||||
| { type: "redirect"; address: string; copy?: boolean }
|
||||
| { type: "discard" }
|
||||
| { type: "keep" }
|
||||
| { type: "reject"; reason: string }
|
||||
| { type: "addflag"; flag: string }
|
||||
| { type: "setflag"; flag: string }
|
||||
| { type: "removeflag"; flag: string }
|
||||
| { type: "markread" }
|
||||
| { type: "flag" }
|
||||
| { type: "stop" };
|
||||
|
||||
export interface SieveRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
join: "allof" | "anyof";
|
||||
tests: SieveTest[];
|
||||
actions: SieveAction[];
|
||||
}
|
||||
|
||||
export const HEADER_CHOICES = [
|
||||
{ value: "from", label: "From" },
|
||||
{ value: "to", label: "To" },
|
||||
{ value: "cc", label: "Cc" },
|
||||
{ value: "subject", label: "Subject" },
|
||||
{ value: "list-id", label: "List-Id" },
|
||||
{ value: "reply-to", label: "Reply-To" },
|
||||
{ value: "x-spam-status", label: "X-Spam-Status" },
|
||||
{ value: "__custom__", label: "Other header…" },
|
||||
];
|
||||
|
||||
export const HEADER_OPS: Array<{ value: HeaderOp; label: string }> = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "notcontains", label: "does not contain" },
|
||||
{ value: "is", label: "is" },
|
||||
{ value: "notis", label: "is not" },
|
||||
{ value: "matches", label: "matches (wildcards * ?)" },
|
||||
{ value: "notmatches", label: "does not match" },
|
||||
{ value: "regex", label: "matches regex" },
|
||||
{ value: "notregex", label: "does not match regex" },
|
||||
{ value: "exists", label: "exists" },
|
||||
{ value: "notexists", label: "does not exist" },
|
||||
];
|
||||
|
||||
export function sieveString(s: string): string {
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ")}"`;
|
||||
}
|
||||
|
||||
function opToSieve(op: HeaderOp): { neg: boolean; match: string } {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
return { neg, match: base === "regex" ? ":regex" : base === "matches" ? ":matches" : base === "is" ? ":is" : base === "exists" ? "exists" : ":contains" };
|
||||
}
|
||||
|
||||
export function testToSieve(t: SieveTest): string {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return "true";
|
||||
case "header": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `header ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "address": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const part = t.part === "all" ? ":all" : t.part === "localpart" ? ":localpart" : ":domain";
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `address ${part} ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "size":
|
||||
return `size :${t.op} ${Math.max(0, Math.round(t.value))}`;
|
||||
case "body": {
|
||||
const inner = `body :text :contains ${sieveString(t.value)}`;
|
||||
return t.op === "notcontains" ? `not ${inner}` : inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function actionToSieve(a: SieveAction): string[] {
|
||||
switch (a.type) {
|
||||
case "fileinto":
|
||||
return [`fileinto${a.copy ? " :copy" : ""} ${sieveString(a.mailbox)};`];
|
||||
case "redirect":
|
||||
return [`redirect${a.copy ? " :copy" : ""} ${sieveString(a.address)};`];
|
||||
case "discard":
|
||||
return ["discard;"];
|
||||
case "keep":
|
||||
return ["keep;"];
|
||||
case "reject":
|
||||
return [`reject ${sieveString(a.reason || "Message rejected")};`];
|
||||
case "addflag":
|
||||
return [`addflag ${sieveString(a.flag)};`];
|
||||
case "setflag":
|
||||
return [`setflag ${sieveString(a.flag)};`];
|
||||
case "removeflag":
|
||||
return [`removeflag ${sieveString(a.flag)};`];
|
||||
case "markread":
|
||||
return ['addflag "\\\\Seen";'];
|
||||
case "flag":
|
||||
return ['addflag "\\\\Flagged";'];
|
||||
case "stop":
|
||||
return ["stop;"];
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredExtensions(rules: SieveRule[]): string[] {
|
||||
const req = new Set<string>();
|
||||
for (const r of rules) {
|
||||
for (const t of r.tests) {
|
||||
if (t.type === "body") req.add("body");
|
||||
if ((t.type === "header" || t.type === "address") && (t.op === "regex" || t.op === "notregex")) req.add("regex");
|
||||
if (t.type === "address") req.add("envelope");
|
||||
}
|
||||
for (const a of r.actions) {
|
||||
if (a.type === "fileinto") {
|
||||
req.add("fileinto");
|
||||
if (a.copy) req.add("copy");
|
||||
}
|
||||
if (a.type === "redirect" && a.copy) req.add("copy");
|
||||
if (a.type === "reject") req.add("reject");
|
||||
if (["addflag", "setflag", "removeflag", "markread", "flag"].includes(a.type)) req.add("imap4flags");
|
||||
}
|
||||
}
|
||||
req.delete("envelope");
|
||||
return [...req].sort();
|
||||
}
|
||||
|
||||
export const SCRIPT_HEADER = "# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments";
|
||||
|
||||
export function rulesToSieve(rules: SieveRule[]): string {
|
||||
const ext = requiredExtensions(rules);
|
||||
const lines: string[] = [SCRIPT_HEADER];
|
||||
if (ext.length) lines.push(`require [${ext.map(sieveString).join(", ")}];`);
|
||||
lines.push("");
|
||||
for (const r of rules) {
|
||||
lines.push(`# rule:${JSON.stringify(r)}`);
|
||||
if (!r.enabled) {
|
||||
lines.push(`# (disabled) ${r.name}`);
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
const tests = r.tests.filter((t) => t.type !== "true");
|
||||
let cond: string;
|
||||
if (!tests.length) cond = "true";
|
||||
else if (tests.length === 1) cond = testToSieve(tests[0]!);
|
||||
else cond = `${r.join} (${tests.map(testToSieve).join(", ")})`;
|
||||
const body = r.actions.flatMap(actionToSieve).map((l) => ` ${l}`);
|
||||
if (!body.length) body.push(" keep;");
|
||||
lines.push(`if ${cond}`);
|
||||
lines.push("{");
|
||||
lines.push(...body);
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
|
||||
export function sieveToRules(script: string): SieveRule[] | null {
|
||||
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
|
||||
const out: SieveRule[] = [];
|
||||
for (const line of script.split(/\r?\n/)) {
|
||||
if (!line.startsWith("# rule:")) continue;
|
||||
try {
|
||||
const r = JSON.parse(line.slice(7)) as SieveRule;
|
||||
if (r && typeof r === "object" && Array.isArray(r.tests) && Array.isArray(r.actions)) out.push(r);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: "New filter",
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests: [{ type: "header", header: "from", op: "contains", value: "" }],
|
||||
actions: [{ type: "fileinto", mailbox: "INBOX" }],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeRule(r: SieveRule): string {
|
||||
const tests = r.tests
|
||||
.map((t) => {
|
||||
switch (t.type) {
|
||||
case "header":
|
||||
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
||||
case "address":
|
||||
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
||||
case "size":
|
||||
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
|
||||
case "body":
|
||||
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
|
||||
case "true":
|
||||
return "always";
|
||||
}
|
||||
})
|
||||
.join(r.join === "allof" ? " and " : " or ");
|
||||
const actions = r.actions
|
||||
.map((a) => {
|
||||
switch (a.type) {
|
||||
case "fileinto":
|
||||
return `move to ${a.mailbox}`;
|
||||
case "redirect":
|
||||
return `forward to ${a.address}`;
|
||||
case "discard":
|
||||
return "delete";
|
||||
case "keep":
|
||||
return "keep";
|
||||
case "reject":
|
||||
return "reject";
|
||||
case "markread":
|
||||
return "mark read";
|
||||
case "flag":
|
||||
return "star";
|
||||
case "addflag":
|
||||
case "setflag":
|
||||
return `add ${a.flag}`;
|
||||
case "removeflag":
|
||||
return `remove ${a.flag}`;
|
||||
case "stop":
|
||||
return "stop";
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
return `${tests || "always"} → ${actions}`;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Client-side evaluation of a visual Sieve rule against existing messages, so a
|
||||
* newly created filter can be applied retroactively to a folder (the server only
|
||||
* runs Sieve on delivery).
|
||||
*/
|
||||
import { client, chunk } from "@/jmap/client";
|
||||
import type { Email, GetResponse, Id, QueryResponse } from "@/jmap/types";
|
||||
import { LIST_PROPS, useMail } from "@/store/mail";
|
||||
import type { SieveRule, SieveTest } from "./sieve";
|
||||
import { domainOf } from "./address";
|
||||
|
||||
function headerValues(e: Email, header: string): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const addr = (list?: { name: string | null; email: string }[] | null) => (list ?? []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email));
|
||||
switch (h) {
|
||||
case "from":
|
||||
return addr(e.from);
|
||||
case "to":
|
||||
return addr(e.to);
|
||||
case "cc":
|
||||
return addr(e.cc);
|
||||
case "bcc":
|
||||
return addr(e.bcc);
|
||||
case "reply-to":
|
||||
return addr(e.replyTo);
|
||||
case "sender":
|
||||
return addr(e.sender);
|
||||
case "subject":
|
||||
return e.subject ? [e.subject] : [];
|
||||
case "message-id":
|
||||
return e.messageId ?? [];
|
||||
default: {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
const key = Object.keys(rec).find((k) => k.toLowerCase().startsWith(`header:${h}:`));
|
||||
const v = key ? rec[key] : undefined;
|
||||
return typeof v === "string" ? [v] : Array.isArray(v) ? (v as string[]) : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addressValues(e: Email, header: string, part: "all" | "localpart" | "domain"): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const list = h === "from" ? e.from : h === "to" ? e.to : h === "cc" ? e.cc : h === "bcc" ? e.bcc : h === "reply-to" ? e.replyTo : h === "sender" ? e.sender : null;
|
||||
return (list ?? []).map((a) => (part === "domain" ? domainOf(a.email) : part === "localpart" ? a.email.split("@")[0] ?? "" : a.email));
|
||||
}
|
||||
|
||||
function wildcardToRegex(pattern: string): RegExp {
|
||||
const esc = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
return new RegExp(`^${esc}$`, "i");
|
||||
}
|
||||
|
||||
function matchOp(values: string[], op: string, value: string): boolean {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
const v = value.toLowerCase();
|
||||
let r: boolean;
|
||||
switch (base) {
|
||||
case "exists":
|
||||
r = values.length > 0;
|
||||
break;
|
||||
case "is":
|
||||
r = values.some((x) => x.toLowerCase() === v);
|
||||
break;
|
||||
case "matches":
|
||||
r = values.some((x) => wildcardToRegex(value).test(x));
|
||||
break;
|
||||
case "regex": {
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(value, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
r = re ? values.some((x) => re!.test(x)) : false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
r = values.some((x) => x.toLowerCase().includes(v));
|
||||
}
|
||||
return neg ? !r : r;
|
||||
}
|
||||
|
||||
export function evaluateTest(e: Email, t: SieveTest, bodyText?: string): boolean {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return true;
|
||||
case "header":
|
||||
return matchOp(headerValues(e, t.header), t.op, t.value);
|
||||
case "address":
|
||||
return matchOp(addressValues(e, t.header, t.part), t.op, t.value);
|
||||
case "size":
|
||||
return t.op === "over" ? e.size > t.value : e.size < t.value;
|
||||
case "body": {
|
||||
const has = (bodyText ?? e.preview ?? "").toLowerCase().includes(t.value.toLowerCase());
|
||||
return t.op === "contains" ? has : !has;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateRule(e: Email, rule: SieveRule, bodyText?: string): boolean {
|
||||
const tests = rule.tests.filter((t) => t.type !== "true");
|
||||
if (!tests.length) return true;
|
||||
return rule.join === "anyof" ? tests.some((t) => evaluateTest(e, t, bodyText)) : tests.every((t) => evaluateTest(e, t, bodyText));
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
scanned: number;
|
||||
matched: number;
|
||||
skippedActions: string[];
|
||||
}
|
||||
|
||||
/** Apply a rule's actions to all matching messages currently in `mailboxId`. */
|
||||
export async function applyRuleToMailbox(rule: SieveRule, mailboxId: Id, onProgress?: (scanned: number, total: number) => void): Promise<ApplyResult> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId;
|
||||
if (!accountId) throw new Error("Not signed in");
|
||||
const customHeaders = rule.tests.filter((t): t is Extract<SieveTest, { type: "header" }> => t.type === "header").map((t) => t.header).filter((h) => !["from", "to", "cc", "bcc", "reply-to", "sender", "subject", "message-id"].includes(h.toLowerCase()));
|
||||
const needsBody = rule.tests.some((t) => t.type === "body");
|
||||
const props = [...LIST_PROPS, "sender", "cc", "bcc", "replyTo", "messageId", ...customHeaders.map((h) => `header:${h}:asText`), ...(needsBody ? ["textBody", "bodyValues"] : [])];
|
||||
|
||||
// Gather all ids in the folder
|
||||
const ids: Id[] = [];
|
||||
let position = 0;
|
||||
let total = 0;
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, sort: [{ property: "receivedAt", isAscending: false }], position, limit: 500, calculateTotal: true });
|
||||
ids.push(...q.ids);
|
||||
total = q.total ?? ids.length;
|
||||
position += q.ids.length;
|
||||
if (!q.ids.length || position >= total) break;
|
||||
}
|
||||
|
||||
const matched: Email[] = [];
|
||||
let scanned = 0;
|
||||
for (const part of chunk(ids, 200)) {
|
||||
const res = await client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: props, ...(needsBody ? { fetchTextBodyValues: true, maxBodyValueBytes: 64 * 1024 } : {}) });
|
||||
for (const e of res.list) {
|
||||
const body = needsBody ? (e.textBody?.[0]?.partId ? e.bodyValues?.[e.textBody[0].partId]?.value : undefined) : undefined;
|
||||
if (evaluateRule(e, rule, body)) matched.push(e);
|
||||
}
|
||||
scanned += part.length;
|
||||
onProgress?.(scanned, ids.length);
|
||||
}
|
||||
|
||||
const skippedActions: string[] = [];
|
||||
if (matched.length) {
|
||||
const mids = matched.map((e) => e.id);
|
||||
const byPath = new Map<string, Id>();
|
||||
for (const m of Object.values(mail.mailboxes)) byPath.set(mail.mailboxPath(m.id).toLowerCase(), m.id);
|
||||
const inboxId = mail.roleId("inbox");
|
||||
for (const a of rule.actions) {
|
||||
switch (a.type) {
|
||||
case "fileinto": {
|
||||
const target = (a.mailboxId && mail.mailboxes[a.mailboxId]?.id) || byPath.get(a.mailbox.toLowerCase()) || (a.mailbox.toLowerCase() === "inbox" ? inboxId : null) || Object.values(mail.mailboxes).find((m) => m.name.toLowerCase() === a.mailbox.toLowerCase())?.id;
|
||||
if (!target) {
|
||||
skippedActions.push(`move to “${a.mailbox}” (folder not found)`);
|
||||
break;
|
||||
}
|
||||
if (target === mailboxId) break;
|
||||
if (a.copy) await mail.addToMailbox(mids, target, true);
|
||||
else await mail.move(mids, target, { silent: true });
|
||||
break;
|
||||
}
|
||||
case "markread":
|
||||
await mail.setKeyword(mids, "$seen", true);
|
||||
break;
|
||||
case "flag":
|
||||
await mail.setKeyword(mids, "$flagged", true);
|
||||
break;
|
||||
case "addflag":
|
||||
case "setflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), true);
|
||||
break;
|
||||
case "removeflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), false);
|
||||
break;
|
||||
case "discard":
|
||||
await mail.trash(mids);
|
||||
break;
|
||||
case "redirect":
|
||||
skippedActions.push(`forward to ${a.address} (cannot resend existing mail)`);
|
||||
break;
|
||||
case "reject":
|
||||
skippedActions.push("reject (cannot bounce existing mail)");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
void mail.refreshList();
|
||||
void mail.loadMailboxes();
|
||||
}
|
||||
return { scanned: ids.length, matched: matched.length, skippedActions };
|
||||
}
|
||||
|
||||
function normalizeFlag(flag: string): string {
|
||||
const f = flag.trim();
|
||||
if (/^\\\\?seen$/i.test(f)) return "$seen";
|
||||
if (/^\\\\?flagged$/i.test(f)) return "$flagged";
|
||||
if (/^\\\\?answered$/i.test(f)) return "$answered";
|
||||
if (/^\\\\?draft$/i.test(f)) return "$draft";
|
||||
return f.replace(/^\\+/, "");
|
||||
}
|
||||
|
||||
/** Seed a rule from a message (used by "Filter messages like this"). */
|
||||
export function ruleFromEmail(e: Email, currentMailboxId: Id | null): SieveRule {
|
||||
const mail = useMail.getState();
|
||||
const from = e.from?.[0]?.email ?? "";
|
||||
const listId = e["header:List-Id:asText"];
|
||||
const tests: SieveTest[] = listId ? [{ type: "header", header: "list-id", op: "contains", value: listId.replace(/^.*<|>.*$/g, "") }] : [{ type: "header", header: "from", op: "contains", value: from }];
|
||||
const target = Object.values(mail.mailboxes).find((m) => !m.role && m.id !== currentMailboxId) ?? Object.values(mail.mailboxes).find((m) => m.role === "archive");
|
||||
const name = listId ? `List: ${listId.replace(/^.*<|>.*$/g, "")}` : `From ${from}`;
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name,
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests,
|
||||
actions: [{ type: "fileinto", mailbox: target ? mail.mailboxPath(target.id) : "INBOX", mailboxId: target?.id }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Helpers to fit rich signatures into Stalwart's 2 KB identity signature limit:
|
||||
* - compactHtml(): strips Office/Gmail cruft and non-essential inline styles
|
||||
* - marker signatures: when still too big, the full HTML lives in Files and the
|
||||
* identity only stores `<!--ihasmail:sig=<blobId>-->` + a plain-text fallback.
|
||||
*/
|
||||
import { escapeHtml, htmlToText } from "./text";
|
||||
|
||||
export const SIGNATURE_LIMIT = 2047;
|
||||
|
||||
const KEEP_STYLES = new Set(["color", "background-color", "font-weight", "font-style", "text-decoration", "font-size", "font-family", "text-align", "vertical-align", "width", "height", "max-width", "border", "border-left", "padding-left", "margin"]);
|
||||
const KEEP_ATTRS = new Set(["href", "src", "alt", "width", "height", "target", "style", "title", "colspan", "rowspan", "cellpadding", "cellspacing", "border", "align", "valign"]);
|
||||
const DROP_TAGS = new Set(["META", "STYLE", "SCRIPT", "LINK", "TITLE", "HEAD", "O:P", "XML", "NOSCRIPT", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON"]);
|
||||
|
||||
export function compactHtml(input: string): string {
|
||||
const doc = new DOMParser().parseFromString(`<div id="r">${input}</div>`, "text/html");
|
||||
const root = doc.getElementById("r")!;
|
||||
// Remove comments and junk elements
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
||||
const comments: Node[] = [];
|
||||
while (walker.nextNode()) comments.push(walker.currentNode);
|
||||
comments.forEach((c) => c.parentNode?.removeChild(c));
|
||||
Array.from(root.querySelectorAll("*"))
|
||||
.filter((el) => DROP_TAGS.has(el.tagName.toUpperCase()) || el.tagName.includes(":"))
|
||||
.forEach((n) => n.remove());
|
||||
// Clean attributes and styles
|
||||
root.querySelectorAll("*").forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (!KEEP_ATTRS.has(attr.name.toLowerCase())) el.removeAttribute(attr.name);
|
||||
}
|
||||
const style = el.getAttribute("style");
|
||||
if (style) {
|
||||
const kept = style
|
||||
.split(";")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.map((d) => {
|
||||
const i = d.indexOf(":");
|
||||
if (i < 0) return null;
|
||||
const k = d.slice(0, i).trim().toLowerCase();
|
||||
let v = d.slice(i + 1).trim();
|
||||
if (!KEEP_STYLES.has(k) || v.startsWith("mso-") || /^(inherit|initial|unset)$/i.test(v)) return null;
|
||||
if (k === "font-family") v = v.split(",")[0]!.trim();
|
||||
if (k === "color" && /^(windowtext|black|#000000|#000|rgb\(0,\s*0,\s*0\))$/i.test(v)) return null;
|
||||
if (k === "background-color" && /^(transparent|white|#fff(fff)?|rgb\(255,\s*255,\s*255\))$/i.test(v)) return null;
|
||||
return `${k}:${v}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
if (kept) el.setAttribute("style", kept);
|
||||
else el.removeAttribute("style");
|
||||
}
|
||||
if (el.tagName === "A" && el.getAttribute("target")) el.removeAttribute("target");
|
||||
});
|
||||
// Unwrap meaningless spans/fonts and empty blocks (repeat until stable)
|
||||
let changed = true;
|
||||
let guard = 0;
|
||||
while (changed && guard++ < 10) {
|
||||
changed = false;
|
||||
root.querySelectorAll("span,font,div,p,b,strong,i,em,u").forEach((el) => {
|
||||
if (!el.parentNode) return;
|
||||
const hasContent = (el.textContent ?? "").trim() !== "" || el.querySelector("img,br,hr,table");
|
||||
if (!hasContent && el.tagName !== "BR") {
|
||||
el.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
if ((el.tagName === "SPAN" || el.tagName === "FONT") && el.attributes.length === 0) {
|
||||
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
|
||||
el.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
// div/p containing only another single div/p: flatten
|
||||
if ((el.tagName === "DIV" || el.tagName === "P") && el.attributes.length === 0 && el.childNodes.length === 1 && el.firstElementChild && (el.firstElementChild.tagName === "DIV" || el.firstElementChild.tagName === "P")) {
|
||||
el.replaceWith(el.firstElementChild);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return root.innerHTML
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.replace(/>\s+</g, "><")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const MARKER_RE = /<!--ihasmail:sig=([A-Za-z0-9_-]+)(?::([\w/+.-]+))?-->/;
|
||||
|
||||
export function markerOf(htmlSignature: string | null | undefined): { blobId: string; type: string } | null {
|
||||
const m = htmlSignature ? MARKER_RE.exec(htmlSignature) : null;
|
||||
return m ? { blobId: m[1]!, type: m[2] ?? "text/html" } : null;
|
||||
}
|
||||
|
||||
/** Build the short identity signature that points at a stored full signature. */
|
||||
export function buildMarkerSignature(blobId: string, fullHtml: string): { htmlSignature: string; textSignature: string } {
|
||||
const text = htmlToText(fullHtml);
|
||||
const marker = `<!--ihasmail:sig=${blobId}:text/html-->`;
|
||||
const budget = SIGNATURE_LIMIT - marker.length - 11; // <div></div>
|
||||
let fallback = escapeHtml(text).replace(/\n/g, "<br>");
|
||||
if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}…`;
|
||||
return { htmlSignature: `${marker}<div>${fallback}</div>`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}…` : text };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Signature images: Stalwart caps identity signatures at 2 KB, so pictures can't
|
||||
* be embedded as data: URLs. Instead we store them in JMAP Files (persistent
|
||||
* blobs) under an "ihasmail" folder and reference them by blob URL; the composer
|
||||
* turns such references into inline cid: parts when sending.
|
||||
*/
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "@/store/session";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
const FOLDER = "ihasmail";
|
||||
|
||||
async function ensureFolder(accountId: string): Promise<string> {
|
||||
let list: FileNode[] = [];
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
||||
]);
|
||||
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
||||
} catch {
|
||||
// Older servers: no filter support — scan everything.
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
||||
]);
|
||||
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
||||
}
|
||||
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
|
||||
if (existing) return existing.id;
|
||||
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } });
|
||||
const err = set.notCreated?.d;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
return set.created!.d!.id;
|
||||
}
|
||||
|
||||
/** Upload an image for use in a signature; returns a same-origin blob URL. */
|
||||
export async function uploadSignatureImage(file: File): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) {
|
||||
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
|
||||
throw new Error("filenode unavailable");
|
||||
}
|
||||
if (file.size > 512 * 1024) {
|
||||
toast.error("Please use an image under 512 KB for signatures.");
|
||||
throw new Error("too large");
|
||||
}
|
||||
try {
|
||||
const type = file.type || "image/png";
|
||||
const up = await client.upload(accountId, file, { type });
|
||||
const folderId = await ensureFolder(accountId);
|
||||
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } });
|
||||
const err = res.notCreated?.f;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
||||
// Prefer the node's (persistent) blobId if the server returned one.
|
||||
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
|
||||
return client.downloadUrl(accountId, blobId, name, type, true);
|
||||
} catch (err) {
|
||||
toast.error(`Could not store image: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
|
||||
export async function storeSignatureHtml(html: string): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
|
||||
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
||||
const folderId = await ensureFolder(accountId);
|
||||
const name = `signature-${Date.now()}.html`;
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } });
|
||||
const err = res.notCreated?.f;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
||||
return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
|
||||
}
|
||||
|
||||
/** Replace data: URL images (pasted pictures) in signature HTML with stored blob URLs. */
|
||||
export async function externalizeDataImages(html: string): Promise<string> {
|
||||
if (!html.includes("data:image/")) return html;
|
||||
const doc = new DOMParser().parseFromString(`<div id="r">${html}</div>`, "text/html");
|
||||
const root = doc.getElementById("r")!;
|
||||
const imgs = Array.from(root.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
|
||||
for (const img of imgs) {
|
||||
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(img.getAttribute("src")!);
|
||||
if (!m) {
|
||||
img.remove();
|
||||
continue;
|
||||
}
|
||||
const bin = atob(m[2]!);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const file = new File([bytes], `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, { type: m[1]! });
|
||||
img.setAttribute("src", await uploadSignatureImage(file));
|
||||
}
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
/** Load the full HTML of a marker signature. */
|
||||
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
|
||||
if (!accountId) throw new Error("no account");
|
||||
return client.fetchBlobText(accountId, blobId, type);
|
||||
}
|
||||
|
||||
async function nodeBlobId(accountId: string, id?: string): Promise<string | undefined> {
|
||||
if (!id) return undefined;
|
||||
try {
|
||||
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
|
||||
return res.list[0]?.blobId ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type { QueryResponse };
|
||||
@@ -0,0 +1,42 @@
|
||||
const PREFIX = "ihasmail:";
|
||||
|
||||
export function loadJson<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
return { ...fallback, ...(JSON.parse(raw) as T) };
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRaw<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* quota exceeded or private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function removeKey(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(PREFIX + key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Namespaced per account so multiple logins on one browser don't collide. */
|
||||
export function accountKey(accountId: string | null | undefined, key: string): string {
|
||||
return `${accountId ?? "anon"}:${key}`;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const URL_RE = /\b((?:https?:\/\/|www\.)[^\s<>"'()]+[^\s<>"'().,;:!?])/gi;
|
||||
const EMAIL_RE = /\b([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})\b/gi;
|
||||
|
||||
/** Convert plain text into safe HTML with links and quote-level coloring. */
|
||||
export function textToHtml(text: string, opts: { linkify?: boolean; quoteColors?: boolean } = {}): string {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
for (const line of lines) {
|
||||
let depth = 0;
|
||||
let rest = line;
|
||||
if (opts.quoteColors !== false) {
|
||||
const m = /^((?:>\s?)+)/.exec(line);
|
||||
if (m) {
|
||||
depth = (m[1]!.match(/>/g) ?? []).length;
|
||||
rest = line.slice(m[1]!.length);
|
||||
// keep markers visually
|
||||
}
|
||||
}
|
||||
const html = opts.linkify === false ? escapeHtml(rest) : linkify(rest);
|
||||
if (depth > 0) {
|
||||
const marker = escapeHtml(line.slice(0, line.length - rest.length));
|
||||
out.push(`<span class="q${Math.min(depth, 3)}">${marker}${html}</span>`);
|
||||
} else out.push(html);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Escape text while turning URLs / email addresses into links (tokenized so escaping never corrupts hrefs). */
|
||||
function linkify(text: string): string {
|
||||
const re = new RegExp(`${URL_RE.source}|${EMAIL_RE.source}`, "gi");
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
out += escapeHtml(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.includes("@") && !/^(https?:\/\/|www\.)/i.test(tok)) {
|
||||
out += `<a href="mailto:${escapeHtml(tok)}">${escapeHtml(tok)}</a>`;
|
||||
} else {
|
||||
const href = tok.startsWith("www.") ? `http://${tok}` : tok;
|
||||
out += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(tok)}</a>`;
|
||||
}
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
out += escapeHtml(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert HTML to reasonably formatted plain text (for text/plain alternative + quoting). */
|
||||
export function htmlToText(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
doc.querySelectorAll("script,style,head,title,noscript").forEach((n) => n.remove());
|
||||
const out: string[] = [];
|
||||
const walk = (node: Node, ctx: { pre: boolean; listIndex: number[]; quote: number }) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const t = node.textContent ?? "";
|
||||
out.push(ctx.pre ? t : t.replace(/\s+/g, " "));
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const block = /^(p|div|section|article|header|footer|h[1-6]|ul|ol|li|table|tr|blockquote|pre|hr|br|address|center|dl|dt|dd|form|fieldset|figure|figcaption)$/.test(tag);
|
||||
if (tag === "br") {
|
||||
out.push("\n");
|
||||
return;
|
||||
}
|
||||
if (tag === "hr") {
|
||||
out.push("\n----------\n");
|
||||
return;
|
||||
}
|
||||
if (tag === "img") {
|
||||
const alt = el.getAttribute("alt");
|
||||
if (alt) out.push(`[${alt}]`);
|
||||
return;
|
||||
}
|
||||
if (block && tag !== "li") out.push("\n");
|
||||
if (/^h[1-6]$/.test(tag)) out.push("\n");
|
||||
if (tag === "li") {
|
||||
const parent = el.parentElement;
|
||||
if (parent?.tagName.toLowerCase() === "ol") {
|
||||
const idx = (ctx.listIndex[ctx.listIndex.length - 1] ?? 0) + 1;
|
||||
ctx.listIndex[ctx.listIndex.length - 1] = idx;
|
||||
out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}${idx}. `);
|
||||
} else out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}- `);
|
||||
}
|
||||
const nextCtx = { ...ctx };
|
||||
if (tag === "pre") nextCtx.pre = true;
|
||||
if (tag === "ul" || tag === "ol") nextCtx.listIndex = [...ctx.listIndex, 0];
|
||||
if (tag === "blockquote") {
|
||||
const start = out.length;
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
const inner = out.splice(start).join("");
|
||||
out.push(
|
||||
"\n" +
|
||||
inner
|
||||
.replace(/^\n+|\n+$/g, "")
|
||||
.split("\n")
|
||||
.map((l) => `> ${l}`)
|
||||
.join("\n") +
|
||||
"\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
const start = out.length;
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
const inner = out.splice(start).join("");
|
||||
const text = inner.trim();
|
||||
if (href && !href.startsWith("mailto:") && text && text !== href && !href.startsWith("#")) out.push(`${text} <${href}>`);
|
||||
else out.push(inner);
|
||||
return;
|
||||
}
|
||||
if (tag === "td" || tag === "th") {
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
out.push("\t");
|
||||
return;
|
||||
}
|
||||
el.childNodes.forEach((c) => walk(c, nextCtx));
|
||||
if (block) out.push("\n");
|
||||
};
|
||||
doc.body.childNodes.forEach((c) => walk(c, { pre: false, listIndex: [], quote: 0 }));
|
||||
return out
|
||||
.join("")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Prefix every line with "> " for plain text quoting. */
|
||||
export function quoteText(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split("\n")
|
||||
.map((l) => (l.startsWith(">") ? `>${l}` : `> ${l}`))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Wrap long lines at width for format=flowed-ish plain text. */
|
||||
export function wrapText(text: string, width = 76): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.length <= width || line.startsWith(">")) return line;
|
||||
const words = line.split(" ");
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
for (const w of words) {
|
||||
if ((cur + " " + w).trim().length > width && cur) {
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
if (cur) lines.push(cur);
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Normalize a subject for reply/forward: strip existing prefixes, add new. */
|
||||
export function replySubject(subject: string | null | undefined, prefix: "Re" | "Fwd"): string {
|
||||
const s = (subject ?? "").trim();
|
||||
const stripped = s.replace(/^((re|fw|fwd|aw|sv|vs|tr|wg)\s*:\s*)+/i, "");
|
||||
if (prefix === "Re" && /^re\s*:/i.test(s)) return s;
|
||||
if (prefix === "Fwd" && /^(fwd?|fw)\s*:/i.test(s)) return s;
|
||||
return `${prefix}: ${stripped}`;
|
||||
}
|
||||
|
||||
/** Detect quoted section boundaries (for "show trimmed content"). Returns index in lines or -1. */
|
||||
export function findQuoteStart(lines: string[]): number {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i]!;
|
||||
if (/^On .+wrote:\s*$/.test(l) || /^-{3,}\s*Original Message\s*-{3,}$/i.test(l) || /^_{5,}$/.test(l) || /^From:\s.+$/.test(l) && i + 1 < lines.length && /^(Sent|Date|To):/.test(lines[i + 1] ?? "")) {
|
||||
return i;
|
||||
}
|
||||
if (l.startsWith(">") && i > 0) {
|
||||
// First run of quote lines after some content
|
||||
let allQuoted = true;
|
||||
for (let j = i; j < Math.min(lines.length, i + 3); j++) if (!lines[j]!.startsWith(">") && lines[j]!.trim() !== "") allQuoted = false;
|
||||
if (allQuoted) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/app.css";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
|
||||
import { settings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export interface EventInstance {
|
||||
/** Unique key for rendering: `${id}` (synthetic ids already unique per instance). */
|
||||
key: string;
|
||||
event: CalendarEvent;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
calendar: Calendar | undefined;
|
||||
}
|
||||
|
||||
interface CalendarState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
calendars: Record<Id, Calendar>;
|
||||
events: Record<Id, CalendarEvent>;
|
||||
/** Loaded ranges keyed "start|end" → event ids */
|
||||
ranges: Record<string, Id[]>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
identities: ParticipantIdentity[];
|
||||
hidden: Record<Id, true>;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadCalendars(): Promise<void>;
|
||||
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
|
||||
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
|
||||
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
||||
destroyCalendar(id: Id): Promise<void>;
|
||||
toggleHidden(id: Id): void;
|
||||
availability(principalId: Id, start: Date, end: Date): Promise<BusyPeriod[]>;
|
||||
findByUid(uid: string): Promise<CalendarEvent | null>;
|
||||
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
|
||||
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
|
||||
applyChanges(types: Set<string>): void;
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit property list: when `properties` is null Stalwart omits the JMAP-only
|
||||
* fields baseEventId / utcStart / utcEnd, and we need baseEventId to update
|
||||
* recurring instances (synthetic ids can't be patched directly).
|
||||
*/
|
||||
const EVENT_PROPS = [
|
||||
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
|
||||
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
|
||||
"recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo",
|
||||
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
|
||||
];
|
||||
|
||||
export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
calendars: {},
|
||||
events: {},
|
||||
ranges: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
identities: [],
|
||||
hidden: {},
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.calendars);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
|
||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||
set({ available });
|
||||
if (!available) return;
|
||||
await get().loadCalendars();
|
||||
try {
|
||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
||||
set({ identities: res.list });
|
||||
} catch {
|
||||
set({ identities: [] });
|
||||
}
|
||||
},
|
||||
|
||||
async loadCalendars() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
|
||||
const calendars: Record<Id, Calendar> = {};
|
||||
for (const c of res.list) calendars[c.id] = c;
|
||||
set({ calendars, error: null });
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async loadRange(start, end, force = false) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
const key = `${start.getTime()}|${end.getTime()}`;
|
||||
if (!force && get().ranges[key]) return;
|
||||
set({ loading: true });
|
||||
const tz = settings().timeZone ?? browserTimeZone;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
[
|
||||
"CalendarEvent/query",
|
||||
{
|
||||
accountId,
|
||||
// Stalwart treats after/before as wall-clock times in `timeZone`.
|
||||
filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) },
|
||||
timeZone: tz,
|
||||
sort: [{ property: "start", isAscending: true }],
|
||||
expandRecurrences: true,
|
||||
limit: 2000,
|
||||
},
|
||||
"q",
|
||||
],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
|
||||
set((s) => {
|
||||
const events = { ...s.events };
|
||||
for (const e of g.list) events[e.id] = e;
|
||||
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
|
||||
});
|
||||
} catch (err) {
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
instancesIn(start, end) {
|
||||
const { events, ranges, calendars, hidden } = get();
|
||||
const ids = new Set<Id>();
|
||||
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
||||
const out: EventInstance[] = [];
|
||||
for (const id of ids) {
|
||||
const e = events[id];
|
||||
if (!e) continue;
|
||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||
if (calId && hidden[calId]) continue;
|
||||
const inst = toInstance(e, calendars);
|
||||
if (!inst) continue;
|
||||
if (inst.end > start && inst.start < end) out.push(inst);
|
||||
}
|
||||
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
|
||||
return out;
|
||||
},
|
||||
|
||||
async getEvent(id) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return null;
|
||||
const res = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: [id], properties: EVENT_PROPS });
|
||||
const e = res.list[0];
|
||||
if (e) set((s) => ({ events: { ...s.events, [e.id]: e } }));
|
||||
return e ?? null;
|
||||
},
|
||||
|
||||
async createEvent(event, calendarId, sendInvites) {
|
||||
const accountId = get().accountId!;
|
||||
const obj = { "@type": "Event", uid: crypto.randomUUID(), ...event, calendarIds: { [calendarId]: true } };
|
||||
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites });
|
||||
const err = res.notCreated?.e;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
get().invalidate();
|
||||
return res.created!.e!.id;
|
||||
},
|
||||
|
||||
async updateEvent(id, patch, sendInvites) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
get().invalidate();
|
||||
},
|
||||
|
||||
async destroyEvent(id, sendInvites) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
set((s) => {
|
||||
const events = { ...s.events };
|
||||
delete events[id];
|
||||
return { events };
|
||||
});
|
||||
get().invalidate();
|
||||
},
|
||||
|
||||
async rsvp(id, status, comment) {
|
||||
const ev = get().events[id] ?? (await get().getEvent(id));
|
||||
if (!ev) throw new Error("Event not found");
|
||||
id = ev.baseEventId ?? id;
|
||||
const mine = myParticipantKeys(ev, get().identities);
|
||||
if (!mine.length) throw new Error("You are not a participant of this event");
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const k of mine) {
|
||||
patch[`participants/${k}/participationStatus`] = status;
|
||||
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
||||
}
|
||||
await get().updateEvent(id, patch, true);
|
||||
},
|
||||
|
||||
async createCalendar(data) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<Calendar>>("Calendar/set", { accountId, create: { c: { name: "Calendar", ...data } } });
|
||||
const err = res.notCreated?.c;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadCalendars();
|
||||
return res.created!.c!.id;
|
||||
},
|
||||
|
||||
async updateCalendar(id, patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [id]: patch } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadCalendars();
|
||||
},
|
||||
|
||||
async destroyCalendar(id) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("Calendar/set", { accountId, destroy: [id], onDestroyRemoveEvents: true });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadCalendars();
|
||||
get().invalidate();
|
||||
},
|
||||
|
||||
toggleHidden(id) {
|
||||
set((s) => {
|
||||
const hidden = { ...s.hidden };
|
||||
if (hidden[id]) delete hidden[id];
|
||||
else hidden[id] = true;
|
||||
return { hidden };
|
||||
});
|
||||
},
|
||||
|
||||
async availability(principalId, start, end) {
|
||||
const accountId = useSession.getState().accountFor(CAP.principals);
|
||||
if (!accountId || !client.hasCapability(CAP.availability)) return [];
|
||||
const res = await client.call<{ list: BusyPeriod[] }>("Principal/getAvailability", { accountId, id: principalId, utcStart: toUTCDate(start), utcEnd: toUTCDate(end), showDetails: false }, [CAP.principals, CAP.availability]);
|
||||
return res.list ?? [];
|
||||
},
|
||||
|
||||
async findByUid(uid) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return null;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["CalendarEvent/query", { accountId, filter: { uid }, limit: 1 }, "q"],
|
||||
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
|
||||
const e = g.list[0];
|
||||
if (e) set((s) => ({ events: { ...s.events, [e.id]: e } }));
|
||||
return e ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async parseIcs(blobId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return [];
|
||||
const res = await client.call<{ parsed?: Record<string, CalendarEvent[] | CalendarEvent>; notParsable?: Id[] }>("CalendarEvent/parse", { accountId, blobIds: [blobId] });
|
||||
const entry = res.parsed?.[blobId];
|
||||
if (!entry) return [];
|
||||
return Array.isArray(entry) ? entry : [entry];
|
||||
},
|
||||
|
||||
async importEvent(event, calendarId) {
|
||||
const { id: _id, calendarIds: _c, baseEventId: _b, utcStart: _us, utcEnd: _ue, isOrigin: _io, method: _m, ...rest } = event as CalendarEvent & { method?: string };
|
||||
return get().createEvent(rest, calendarId, false);
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("Calendar")) void get().loadCalendars();
|
||||
if (types.has("CalendarEvent")) get().invalidate();
|
||||
},
|
||||
|
||||
invalidate() {
|
||||
// Force reload of all ranges currently cached.
|
||||
const keys = Object.keys(get().ranges);
|
||||
set({ ranges: {} });
|
||||
for (const k of keys) {
|
||||
const [s, e] = k.split("|").map(Number) as [number, number];
|
||||
void get().loadRange(new Date(s), new Date(e), true);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
|
||||
const allDay = Boolean(e.showWithoutTime);
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
if (e.utcStart && e.utcEnd && !allDay) {
|
||||
start = new Date(e.utcStart);
|
||||
end = new Date(e.utcEnd);
|
||||
} else {
|
||||
const tz = allDay ? null : e.timeZone;
|
||||
start = zonedToDate(e.start, tz);
|
||||
const dur = parseDuration(e.duration);
|
||||
end = new Date(start.getTime() + (dur || (allDay ? 86400 : 0)) * 1000);
|
||||
if (allDay && end.getTime() - start.getTime() < DAY_MS) end = new Date(start.getTime() + DAY_MS);
|
||||
}
|
||||
if (Number.isNaN(start.getTime())) return null;
|
||||
if (end <= start) end = new Date(start.getTime() + (allDay ? DAY_MS : 30 * 60_000));
|
||||
const calId = Object.keys(e.calendarIds ?? {})[0];
|
||||
return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined };
|
||||
}
|
||||
|
||||
export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] {
|
||||
const mine = new Set<string>();
|
||||
for (const i of identities) {
|
||||
mine.add(i.calendarAddress.toLowerCase());
|
||||
for (const v of Object.values(i.sendTo ?? {})) mine.add(v.toLowerCase());
|
||||
}
|
||||
const session = useSession.getState().session;
|
||||
if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`);
|
||||
const keys: string[] = [];
|
||||
for (const [k, p] of Object.entries(ev.participants ?? {})) {
|
||||
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
|
||||
if (addrs.some((a) => mine.has(a))) keys.push(k);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
useSession.subscribe((s) => {
|
||||
if (s.status !== "authenticated") useCalendar.setState({ accountId: null, calendars: {}, events: {}, ranges: {}, identities: [] });
|
||||
});
|
||||
@@ -0,0 +1,678 @@
|
||||
import { create } from "zustand";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { settings } from "./settings";
|
||||
|
||||
export interface ComposeAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
blobId: Id | null;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
file?: File;
|
||||
cid?: string;
|
||||
inline?: boolean;
|
||||
abort?: AbortController;
|
||||
}
|
||||
|
||||
export type Priority = "high" | "normal" | "low";
|
||||
|
||||
export interface Draft {
|
||||
key: string;
|
||||
draftId: Id | null;
|
||||
identityId: Id | null;
|
||||
to: EmailAddress[];
|
||||
cc: EmailAddress[];
|
||||
bcc: EmailAddress[];
|
||||
/** Per-message Reply-To (defaults to the identity's Reply-To). */
|
||||
replyTo: EmailAddress[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
format: "html" | "text";
|
||||
attachments: ComposeAttachment[];
|
||||
inReplyTo: string[] | null;
|
||||
references: string[] | null;
|
||||
relatedEmailId: Id | null;
|
||||
relatedKeyword: "$answered" | "$forwarded" | null;
|
||||
requestReceipt: boolean;
|
||||
priority: Priority;
|
||||
showCc: boolean;
|
||||
showBcc: boolean;
|
||||
showReplyTo: boolean;
|
||||
minimized: boolean;
|
||||
maximized: boolean;
|
||||
dirty: boolean;
|
||||
savedAt: number | null;
|
||||
saving: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
/** Original identity signature HTML currently embedded, to replace on identity switch. */
|
||||
signatureHtml: string;
|
||||
replyMode: "reply" | "replyAll" | "forward" | null;
|
||||
mailboxIdOnSend?: Id | null;
|
||||
}
|
||||
|
||||
interface ComposeState {
|
||||
drafts: Draft[];
|
||||
activeKey: string | null;
|
||||
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
|
||||
open(init?: Partial<Draft>): string;
|
||||
openDraftEmail(email: Email): Promise<string>;
|
||||
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
|
||||
update(key: string, patch: Partial<Draft>): void;
|
||||
close(key: string, opts?: { discard?: boolean }): Promise<void>;
|
||||
focus(key: string): void;
|
||||
addFiles(key: string, files: File[]): void;
|
||||
removeAttachment(key: string, attId: string): void;
|
||||
saveDraft(key: string, opts?: { silent?: boolean }): Promise<Id | null>;
|
||||
send(key: string): Promise<void>;
|
||||
undoSend(key: string): void;
|
||||
setIdentity(key: string, identityId: Id): void;
|
||||
insertTemplate(key: string, html: string, subject?: string): void;
|
||||
}
|
||||
|
||||
const AUTOSAVE_MS = 20_000;
|
||||
const autosaveTimers = new Map<string, number>();
|
||||
|
||||
function blankDraft(init: Partial<Draft> = {}): Draft {
|
||||
const s = settings();
|
||||
return {
|
||||
key: uid("d"),
|
||||
draftId: null,
|
||||
identityId: null,
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
subject: "",
|
||||
html: "",
|
||||
text: "",
|
||||
format: s.composeFormat,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
references: null,
|
||||
relatedEmailId: null,
|
||||
relatedKeyword: null,
|
||||
requestReceipt: s.requestReadReceipt,
|
||||
priority: "normal",
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
showReplyTo: false,
|
||||
minimized: false,
|
||||
maximized: false,
|
||||
dirty: false,
|
||||
savedAt: null,
|
||||
saving: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
signatureHtml: "",
|
||||
replyMode: null,
|
||||
...init,
|
||||
};
|
||||
}
|
||||
|
||||
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
|
||||
if (!identity) return "";
|
||||
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
|
||||
if (identity.htmlSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>${sanitizeEditorHtml(identity.htmlSignature)}</div>`;
|
||||
if (identity.textSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>-- <br>${textToHtml(identity.textSignature, { quoteColors: false }).replace(/\n/g, "<br>")}</div>`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function defaultIdentity(identities: Identity[], email?: Email | null): Identity | undefined {
|
||||
if (!identities.length) return undefined;
|
||||
if (email) {
|
||||
const candidates = [...(email.to ?? []), ...(email.cc ?? []), ...(email.bcc ?? [])];
|
||||
for (const c of candidates) {
|
||||
const m = identities.find((i) => sameAddress(i.email, c.email));
|
||||
if (m) return m;
|
||||
}
|
||||
}
|
||||
return useMail.getState().defaultIdentity() ?? identities[0];
|
||||
}
|
||||
|
||||
export const useCompose = create<ComposeState>((set, get) => ({
|
||||
drafts: [],
|
||||
activeKey: null,
|
||||
pendingSends: {},
|
||||
|
||||
open(init = {}) {
|
||||
const identities = useMail.getState().identities;
|
||||
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
|
||||
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
|
||||
if (!init.html && !init.text && ident) {
|
||||
d.signatureHtml = signatureBlock(ident, "html");
|
||||
d.html = `<div><br></div>${d.signatureHtml}`;
|
||||
d.text = signatureBlock(ident, "text");
|
||||
}
|
||||
set((s) => ({ drafts: [...s.drafts.map((x) => ({ ...x, minimized: s.drafts.length >= 1 ? x.minimized : x.minimized })), d], activeKey: d.key }));
|
||||
return d.key;
|
||||
},
|
||||
|
||||
async openDraftEmail(email) {
|
||||
const existing = get().drafts.find((d) => d.draftId === email.id);
|
||||
if (existing) {
|
||||
get().focus(existing.key);
|
||||
return existing.key;
|
||||
}
|
||||
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email;
|
||||
const identities = useMail.getState().identities;
|
||||
const ident = identities.find((i) => full.from?.some((f) => sameAddress(f.email, i.email))) ?? useMail.getState().defaultIdentity() ?? identities[0];
|
||||
const htmlPart = full.htmlBody?.[0];
|
||||
const textPart = full.textBody?.[0];
|
||||
const html = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
const text = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
const accountId = useMail.getState().accountId!;
|
||||
const cidMap: Record<string, string> = {};
|
||||
const attachments: ComposeAttachment[] = [];
|
||||
for (const a of full.attachments ?? []) {
|
||||
const inline = Boolean(a.cid) && (a.disposition === "inline" || a.type.startsWith("image/"));
|
||||
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
|
||||
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
|
||||
}
|
||||
const d = blankDraft({
|
||||
draftId: full.id,
|
||||
identityId: ident?.id ?? null,
|
||||
to: full.to ?? [],
|
||||
cc: full.cc ?? [],
|
||||
bcc: full.bcc ?? [],
|
||||
replyTo: full.replyTo ?? ident?.replyTo ?? [],
|
||||
showReplyTo: Boolean(full.replyTo?.length || ident?.replyTo?.length),
|
||||
showCc: Boolean(full.cc?.length),
|
||||
showBcc: Boolean(full.bcc?.length),
|
||||
subject: full.subject ?? "",
|
||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||
text: text || (html ? htmlToText(html) : ""),
|
||||
format: html ? "html" : settings().composeFormat,
|
||||
attachments,
|
||||
inReplyTo: full.inReplyTo ?? null,
|
||||
references: full.references ?? null,
|
||||
requestReceipt: Boolean(full["header:Disposition-Notification-To:asAddresses"]?.length),
|
||||
priority: /^[12]/.test(full["header:X-Priority:asText"] ?? "") ? "high" : /^[45]/.test(full["header:X-Priority:asText"] ?? "") ? "low" : "normal",
|
||||
});
|
||||
set((s) => ({ drafts: [...s.drafts, d], activeKey: d.key }));
|
||||
return d.key;
|
||||
},
|
||||
|
||||
async reply(email, mode) {
|
||||
const mail = useMail.getState();
|
||||
const full = (await mail.getEmails([email.id], true))[0] ?? email;
|
||||
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
|
||||
const ident = defaultIdentity(identities, full);
|
||||
const ownEmails = identities.map((i) => i.email.toLowerCase());
|
||||
const isOwn = (a: EmailAddress) => ownEmails.includes(a.email.toLowerCase());
|
||||
const s = settings();
|
||||
|
||||
let to: EmailAddress[] = [];
|
||||
let cc: EmailAddress[] = [];
|
||||
if (mode === "reply" || mode === "replyAll") {
|
||||
const replyTo = full.replyTo?.length ? full.replyTo : (full.from ?? []);
|
||||
to = uniqueAddresses(replyTo);
|
||||
if (mode === "replyAll") {
|
||||
const others = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
|
||||
cc = others;
|
||||
// If the message was sent by me, reply to original recipients instead.
|
||||
if (to.every(isOwn) && full.to?.length) {
|
||||
to = uniqueAddresses(full.to);
|
||||
cc = uniqueAddresses(full.cc ?? []).filter((a) => !isOwn(a));
|
||||
}
|
||||
} else if (to.every(isOwn) && full.to?.length) {
|
||||
to = uniqueAddresses(full.to.filter((a) => !isOwn(a)));
|
||||
if (!to.length) to = uniqueAddresses(full.to);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlPart = full.htmlBody?.[0];
|
||||
const textPart = full.textBody?.[0];
|
||||
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
const accountId = mail.accountId!;
|
||||
const attachments: ComposeAttachment[] = [];
|
||||
const cidMap: Record<string, string> = {};
|
||||
for (const a of full.attachments ?? []) {
|
||||
const inline = Boolean(a.cid) && a.type.startsWith("image/");
|
||||
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
|
||||
if (mode === "forward" || inline) {
|
||||
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
|
||||
}
|
||||
}
|
||||
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
|
||||
const quotedHtmlBody = origHtml
|
||||
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false }).html
|
||||
: textToHtml(origText).replace(/\n/g, "<br>");
|
||||
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
|
||||
const date = formatFullDate(full.receivedAt);
|
||||
let quoteHtml = "";
|
||||
let quoteTxt = "";
|
||||
if (mode === "forward") {
|
||||
const hdr = [
|
||||
`From: ${(full.from ?? []).map(formatAddress).join(", ")}`,
|
||||
`Date: ${date}`,
|
||||
`Subject: ${full.subject ?? ""}`,
|
||||
`To: ${(full.to ?? []).map(formatAddress).join(", ")}`,
|
||||
...(full.cc?.length ? [`Cc: ${full.cc.map(formatAddress).join(", ")}`] : []),
|
||||
];
|
||||
quoteHtml = `<div class="ihm-quote"><br><div>---------- Forwarded message ---------</div><div>${hdr.map(escapeHtml).join("<br>")}</div><br>${quotedHtmlBody}</div>`;
|
||||
quoteTxt = `\n\n---------- Forwarded message ---------\n${hdr.join("\n")}\n\n${origText || (origHtml ? htmlToText(origHtml) : "")}`;
|
||||
} else if (s.includeQuote) {
|
||||
quoteHtml = `<div class="ihm-quote"><br><div>On ${escapeHtml(date)}, ${fromStr} wrote:</div><blockquote style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">${quotedHtmlBody}</blockquote></div>`;
|
||||
quoteTxt = `\n\nOn ${date}, ${(full.from ?? []).map(formatAddress).join(", ")} wrote:\n${quoteTextOf(origText, origHtml)}`;
|
||||
}
|
||||
const sigHtml = signatureBlock(ident, "html");
|
||||
const sigText = signatureBlock(ident, "text");
|
||||
const html = s.signatureAboveQuote ? `<div><br></div>${sigHtml}${quoteHtml}` : `<div><br></div>${quoteHtml}${sigHtml}`;
|
||||
const text = s.signatureAboveQuote ? `${sigText}${quoteTxt}` : `${quoteTxt}${sigText}`;
|
||||
const messageId = full.messageId?.[0];
|
||||
const d = blankDraft({
|
||||
identityId: ident?.id ?? null,
|
||||
to,
|
||||
cc,
|
||||
showCc: cc.length > 0,
|
||||
replyTo: ident?.replyTo ?? [],
|
||||
showReplyTo: Boolean(ident?.replyTo?.length),
|
||||
subject: replySubject(full.subject, mode === "forward" ? "Fwd" : "Re"),
|
||||
html,
|
||||
text,
|
||||
format: s.composeFormat,
|
||||
attachments,
|
||||
inReplyTo: mode === "forward" ? null : messageId ? [messageId] : null,
|
||||
references: mode === "forward" ? null : messageId ? [...(full.references ?? []), messageId] : (full.references ?? null),
|
||||
relatedEmailId: full.id,
|
||||
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
|
||||
signatureHtml: sigHtml,
|
||||
replyMode: mode,
|
||||
});
|
||||
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
||||
return d.key;
|
||||
},
|
||||
|
||||
update(key, patch) {
|
||||
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) }));
|
||||
if (isContentPatch(patch)) scheduleAutosave(key, get);
|
||||
},
|
||||
|
||||
async close(key, opts = {}) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const t = autosaveTimers.get(key);
|
||||
if (t) window.clearTimeout(t);
|
||||
autosaveTimers.delete(key);
|
||||
for (const a of d.attachments) a.abort?.abort();
|
||||
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? (s.drafts.find((x) => x.key !== key)?.key ?? null) : s.activeKey }));
|
||||
if (opts.discard) {
|
||||
if (d.draftId) {
|
||||
try {
|
||||
await client.call("Email/set", { accountId: useMail.getState().accountId, destroy: [d.draftId] });
|
||||
void useMail.getState().refreshList();
|
||||
void useMail.getState().loadMailboxes();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
toast.show("Draft discarded");
|
||||
return;
|
||||
}
|
||||
if (d.dirty && (d.to.length || d.subject || hasContent(d))) {
|
||||
try {
|
||||
await saveDraftInternal(d, get, set, { silent: true, final: true });
|
||||
toast.show("Draft saved");
|
||||
} catch (err) {
|
||||
toast.error(`Could not save draft: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
focus(key) {
|
||||
set((s) => ({ activeKey: key, drafts: s.drafts.map((d) => (d.key === key ? { ...d, minimized: false } : d)) }));
|
||||
},
|
||||
|
||||
addFiles(key, files) {
|
||||
const accountId = useMail.getState().accountId;
|
||||
if (!accountId) return;
|
||||
const max = client.maxSizeUpload;
|
||||
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, file: f }));
|
||||
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
|
||||
for (const a of atts) {
|
||||
if (a.error || !a.file) continue;
|
||||
const abort = new AbortController();
|
||||
a.abort = abort;
|
||||
client
|
||||
.upload(accountId, a.file, {
|
||||
type: a.type,
|
||||
signal: abort.signal,
|
||||
onProgress: (loaded, total) => patchAtt(key, a.id, { progress: Math.round((loaded / total) * 100) }, set),
|
||||
})
|
||||
.then((res) => patchAtt(key, a.id, { blobId: res.blobId, progress: 100, type: res.type || a.type, size: res.size }, set))
|
||||
.catch((err) => patchAtt(key, a.id, { error: (err as Error).message || "Upload failed" }, set));
|
||||
}
|
||||
},
|
||||
|
||||
removeAttachment(key, attId) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
const a = d?.attachments.find((x) => x.id === attId);
|
||||
a?.abort?.abort();
|
||||
get().update(key, { attachments: (d?.attachments ?? []).filter((x) => x.id !== attId) });
|
||||
},
|
||||
|
||||
async saveDraft(key, opts = {}) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return null;
|
||||
try {
|
||||
return await saveDraftInternal(d, get, set, { silent: opts.silent ?? false });
|
||||
} catch (err) {
|
||||
if (!opts.silent) toast.error(`Could not save draft: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async send(key) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const delay = settings().undoSendSeconds;
|
||||
// Hide the composer immediately; actually send after the undo window.
|
||||
const t = autosaveTimers.get(key);
|
||||
if (t) window.clearTimeout(t);
|
||||
autosaveTimers.delete(key);
|
||||
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? null : s.activeKey }));
|
||||
const doSend = async () => {
|
||||
set((s) => {
|
||||
const { [key]: _drop, ...rest } = s.pendingSends;
|
||||
return { pendingSends: rest };
|
||||
});
|
||||
try {
|
||||
await sendInternal(d, get);
|
||||
toast.success("Message sent");
|
||||
} catch (err) {
|
||||
toast.error(`Send failed: ${(err as Error).message}`, {
|
||||
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||
duration: 15000,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (delay <= 0) {
|
||||
await doSend();
|
||||
return;
|
||||
}
|
||||
const toastId = toast.show("Sending…", { duration: delay * 1000, progress: true, action: { label: "Undo", onClick: () => get().undoSend(key) } });
|
||||
const timer = window.setTimeout(() => void doSend(), delay * 1000);
|
||||
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } }));
|
||||
},
|
||||
|
||||
undoSend(key) {
|
||||
const p = get().pendingSends[key];
|
||||
if (!p) return;
|
||||
window.clearTimeout(p.timer);
|
||||
toast.dismiss(p.toastId);
|
||||
set((s) => {
|
||||
const { [key]: _drop, ...rest } = s.pendingSends;
|
||||
return { pendingSends: rest, drafts: [...s.drafts, { ...p.draft, sending: false }], activeKey: key };
|
||||
});
|
||||
},
|
||||
|
||||
setIdentity(key, identityId) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const ident = useMail.getState().identities.find((i) => i.id === identityId);
|
||||
const newSig = signatureBlock(ident, "html");
|
||||
let html = d.html;
|
||||
if (d.signatureHtml && html.includes(d.signatureHtml)) html = html.replace(d.signatureHtml, newSig);
|
||||
else if (!d.signatureHtml && newSig) {
|
||||
// insert before quote if any, else append
|
||||
const idx = html.indexOf('<div class="ihm-quote">');
|
||||
html = idx >= 0 ? html.slice(0, idx) + newSig + html.slice(idx) : html + newSig;
|
||||
}
|
||||
// Plain text: replace trailing signature block
|
||||
const oldSigText = signatureBlock(useMail.getState().identities.find((i) => i.id === d.identityId), "text");
|
||||
let text = d.text;
|
||||
if (oldSigText && text.includes(oldSigText)) text = text.replace(oldSigText, signatureBlock(ident, "text"));
|
||||
const oldIdent = useMail.getState().identities.find((i) => i.id === d.identityId);
|
||||
const sameList = (a: EmailAddress[], b: EmailAddress[]) => a.length === b.length && a.every((x, i) => sameAddress(x.email, b[i]?.email));
|
||||
const replyToPatch = sameList(d.replyTo, oldIdent?.replyTo ?? []) ? { replyTo: ident?.replyTo ?? [], showReplyTo: d.showReplyTo || Boolean(ident?.replyTo?.length) } : {};
|
||||
get().update(key, { identityId, html, text, signatureHtml: newSig, ...replyToPatch });
|
||||
},
|
||||
|
||||
insertTemplate(key, html, subject) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
|
||||
if (subject && !d.subject) patch.subject = subject;
|
||||
get().update(key, patch);
|
||||
},
|
||||
}));
|
||||
|
||||
function quoteTextOf(text: string, html: string): string {
|
||||
const base = text || (html ? htmlToText(html) : "");
|
||||
return quoteText(base);
|
||||
}
|
||||
|
||||
function isContentPatch(p: Partial<Draft>): boolean {
|
||||
return ["to", "cc", "bcc", "replyTo", "subject", "html", "text", "attachments", "identityId", "format", "priority", "requestReceipt"].some((k) => k in p);
|
||||
}
|
||||
|
||||
function hasContent(d: Draft): boolean {
|
||||
const body = d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text;
|
||||
return body.replace(/--\s*[\s\S]*$/, "").trim().length > 0 || d.attachments.length > 0;
|
||||
}
|
||||
|
||||
function patchAtt(key: string, attId: string, patch: Partial<ComposeAttachment>, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void) {
|
||||
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, dirty: true, attachments: d.attachments.map((a) => (a.id === attId ? { ...a, ...patch } : a)) } : d)) }));
|
||||
}
|
||||
|
||||
function scheduleAutosave(key: string, get: () => ComposeState) {
|
||||
const t = autosaveTimers.get(key);
|
||||
if (t) window.clearTimeout(t);
|
||||
autosaveTimers.set(
|
||||
key,
|
||||
window.setTimeout(() => {
|
||||
autosaveTimers.delete(key);
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (d && d.dirty && !d.sending && (d.to.length || d.subject || hasContent(d))) void get().saveDraft(key, { silent: true });
|
||||
}, AUTOSAVE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the JMAP Email creation object from a draft. */
|
||||
async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
if (!ident) throw new Error("No sending identity available");
|
||||
const from: EmailAddress = { name: ident.name || null, email: ident.email };
|
||||
|
||||
let html = d.format === "html" ? d.html : "";
|
||||
const text = d.format === "html" ? htmlToText(d.html) : d.text;
|
||||
|
||||
// Inline attachments shown via blob URLs in the editor → back to cid: references.
|
||||
for (const a of d.attachments) {
|
||||
if (a.inline && a.cid && a.blobId && html) {
|
||||
const re = new RegExp(`/api/blob/[^"' )]*${a.blobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"' )]*`, "g");
|
||||
html = html.replace(re, `cid:${a.cid}`);
|
||||
}
|
||||
}
|
||||
// Inline images (data: URLs from the editor) → upload and reference by cid.
|
||||
const related: EmailBodyPart[] = [];
|
||||
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = [];
|
||||
// Images referencing stored blobs (e.g. signature logos kept in Files) → inline cid parts.
|
||||
if (html && html.includes("/api/blob/")) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of Array.from(doc.querySelectorAll("img"))) {
|
||||
const src = img.getAttribute("src") ?? "";
|
||||
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src);
|
||||
if (!m) continue;
|
||||
const blobId = decodeURIComponent(m[2]!);
|
||||
const name = decodeURIComponent(m[3]!);
|
||||
const type = new URLSearchParams(m[4] ?? "").get("accept") ?? "image/png";
|
||||
const cid = `${uid("img")}@ihasmail`;
|
||||
img.setAttribute("src", `cid:${cid}`);
|
||||
relatedInline.push({ blobId, type, name, cid });
|
||||
}
|
||||
html = doc.body.innerHTML;
|
||||
}
|
||||
if (html && html.includes("data:image/")) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const imgs = Array.from(doc.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
|
||||
for (const img of imgs) {
|
||||
const src = img.getAttribute("src")!;
|
||||
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(src);
|
||||
if (!m) continue;
|
||||
const bin = atob(m[2]!);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const up = await client.upload(accountId, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
|
||||
const cid = `${uid("img")}@ihasmail`;
|
||||
img.setAttribute("src", `cid:${cid}`);
|
||||
relatedInline.push({ blobId: up.blobId, type: m[1]!, name: `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, cid });
|
||||
}
|
||||
html = doc.body.innerHTML;
|
||||
}
|
||||
// Existing inline attachments referenced via cid (from reply/forward/draft) stay as related parts.
|
||||
for (const a of d.attachments) {
|
||||
if (a.inline && a.cid && a.blobId && html.includes(`cid:${a.cid}`)) relatedInline.push({ blobId: a.blobId, type: a.type, name: a.name, cid: a.cid });
|
||||
}
|
||||
for (const r of relatedInline) {
|
||||
related.push({ partId: null, blobId: r.blobId, size: 0, name: r.name, type: r.type, charset: null, disposition: "inline", cid: r.cid });
|
||||
}
|
||||
|
||||
const bodyValues: Record<string, { value: string }> = {};
|
||||
const alternative: Record<string, unknown>[] = [];
|
||||
bodyValues.text = { value: text };
|
||||
alternative.push({ partId: "text", type: "text/plain" });
|
||||
if (html) {
|
||||
bodyValues.html = { value: wrapHtmlDocument(html) };
|
||||
const htmlPart: Record<string, unknown> = { partId: "html", type: "text/html" };
|
||||
if (related.length) alternative.push({ type: "multipart/related", subParts: [htmlPart, ...related.map(stripPart)] });
|
||||
else alternative.push(htmlPart);
|
||||
}
|
||||
const regular = d.attachments.filter((a) => !a.inline && a.blobId && !a.error);
|
||||
let bodyStructure: Record<string, unknown>;
|
||||
const alt = html ? { type: "multipart/alternative", subParts: alternative } : alternative[0]!;
|
||||
if (regular.length) {
|
||||
bodyStructure = { type: "multipart/mixed", subParts: [alt, ...regular.map((a) => ({ blobId: a.blobId, type: a.type, name: a.name, disposition: "attachment" }))] };
|
||||
} else bodyStructure = alt;
|
||||
|
||||
const obj: Record<string, unknown> = {
|
||||
from: [from],
|
||||
to: d.to.length ? d.to : null,
|
||||
cc: d.cc.length ? d.cc : null,
|
||||
bcc: d.bcc.length ? d.bcc : null,
|
||||
replyTo: d.replyTo.length ? d.replyTo : ident.replyTo?.length ? ident.replyTo : null,
|
||||
subject: d.subject,
|
||||
sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
inReplyTo: d.inReplyTo,
|
||||
references: d.references,
|
||||
bodyStructure,
|
||||
bodyValues,
|
||||
"header:User-Agent:asText": "ihasmail/2.0",
|
||||
};
|
||||
if (d.priority === "high") {
|
||||
obj["header:X-Priority:asText"] = "1 (Highest)";
|
||||
obj["header:Importance:asText"] = "High";
|
||||
} else if (d.priority === "low") {
|
||||
obj["header:X-Priority:asText"] = "5 (Lowest)";
|
||||
obj["header:Importance:asText"] = "Low";
|
||||
}
|
||||
if (d.requestReceipt) obj["header:Disposition-Notification-To:asAddresses"] = [from];
|
||||
if (!opts.forSend) {
|
||||
const draftsId = mail.roleId("drafts");
|
||||
obj.mailboxIds = draftsId ? { [draftsId]: true } : { [mail.roleId("inbox")!]: true };
|
||||
obj.keywords = { $draft: true, $seen: true };
|
||||
} else {
|
||||
const sentId = mail.roleId("sent") ?? mail.roleId("inbox");
|
||||
obj.mailboxIds = { [sentId!]: true };
|
||||
obj.keywords = { $seen: true };
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function stripPart(p: EmailBodyPart): Record<string, unknown> {
|
||||
return { blobId: p.blobId, type: p.type, name: p.name, disposition: p.disposition, cid: p.cid };
|
||||
}
|
||||
|
||||
function wrapHtmlDocument(body: string): string {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;">${body}</body></html>`;
|
||||
}
|
||||
|
||||
async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void, opts: { silent: boolean; final?: boolean }): Promise<Id | null> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: true } : x)) }));
|
||||
try {
|
||||
const email = await buildEmailObject(d, { forSend: false });
|
||||
const args: Record<string, unknown> = { accountId, create: { draft: email } };
|
||||
if (d.draftId) args.destroy = [d.draftId];
|
||||
const res = await client.call<SetResponse<Email>>("Email/set", args);
|
||||
const err = res.notCreated?.draft;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const newId = res.created?.draft?.id ?? null;
|
||||
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, draftId: newId, saving: false, dirty: false, savedAt: Date.now(), error: null } : x)) }));
|
||||
void mail.loadMailboxes();
|
||||
if (mail.list?.mailboxId && mail.list.mailboxId === mail.roleId("drafts")) void mail.refreshList();
|
||||
return newId;
|
||||
} catch (err) {
|
||||
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: false, error: (err as Error).message } : x)) }));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
if (!ident) throw new Error("No sending identity available");
|
||||
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading");
|
||||
const email = await buildEmailObject(d, { forSend: true });
|
||||
const sentId = mail.roleId("sent");
|
||||
const draftsId = mail.roleId("drafts");
|
||||
const onSuccess: Record<string, unknown> = { "keywords/$draft": null, "keywords/$seen": true };
|
||||
if (sentId) onSuccess[`mailboxIds/${sentId}`] = true;
|
||||
if (draftsId) onSuccess[`mailboxIds/${draftsId}`] = null;
|
||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||
if (!rcpts.length) throw new Error("No recipients");
|
||||
const calls: Array<[string, Record<string, unknown>, string]> = [
|
||||
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
|
||||
[
|
||||
"EmailSubmission/set",
|
||||
{
|
||||
accountId,
|
||||
create: { s: { identityId: ident.id, emailId: "#m", envelope: { mailFrom: { email: ident.email }, rcptTo: rcpts } } },
|
||||
onSuccessUpdateEmail: { "#s": onSuccess },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
];
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
||||
}
|
||||
const res = await client.chain(calls, { allowErrors: true });
|
||||
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
||||
if (e.__error) throw new Error(e.__error.description ?? e.__error.type);
|
||||
if (e.notCreated?.m) throw new Error(e.notCreated.m.description ?? e.notCreated.m.type);
|
||||
const s = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
|
||||
if (s.__error) throw new Error(s.__error.description ?? s.__error.type);
|
||||
if (s.notCreated?.s) {
|
||||
const err = s.notCreated.s;
|
||||
// Clean up the created (unsent) email so it doesn't linger in Sent.
|
||||
const created = e.created?.m?.id;
|
||||
if (created) void client.call("Email/set", { accountId, destroy: [created] });
|
||||
throw new Error(err.description ?? err.type);
|
||||
}
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
useMail.setState((st) => {
|
||||
const cur = st.emails[d.relatedEmailId!];
|
||||
return cur ? { emails: { ...st.emails, [d.relatedEmailId!]: { ...cur, keywords: { ...cur.keywords, [d.relatedKeyword!]: true } } } } : {};
|
||||
});
|
||||
}
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
}
|
||||
|
||||
export { FULL_PROPS, BODY_PROPS };
|
||||
@@ -0,0 +1,320 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
export interface Suggestion {
|
||||
name: string | null;
|
||||
email: string;
|
||||
source: "contact" | "gal" | "recent";
|
||||
contactId?: Id;
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
interface ContactsState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
books: Record<Id, AddressBook>;
|
||||
cards: Record<Id, ContactCard>;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
principals: Principal[];
|
||||
principalsLoaded: boolean;
|
||||
recent: EmailAddress[];
|
||||
|
||||
init(): Promise<void>;
|
||||
loadBooks(): Promise<void>;
|
||||
loadAll(): Promise<void>;
|
||||
getCard(id: Id): Promise<ContactCard | null>;
|
||||
search(text: string): ContactCard[];
|
||||
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
|
||||
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
|
||||
destroyCards(ids: Id[]): Promise<void>;
|
||||
createBook(name: string): Promise<Id>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
importVCard(text: string, addressBookId: Id): Promise<number>;
|
||||
loadPrincipals(): Promise<void>;
|
||||
suggest(query: string, limit?: number): Promise<Suggestion[]>;
|
||||
addRecent(addrs: EmailAddress[]): void;
|
||||
lookupByEmail(email: string): ContactCard | undefined;
|
||||
applyChanges(types: Set<string>): void;
|
||||
}
|
||||
|
||||
export const CARD_PROPS = undefined; // all properties
|
||||
|
||||
export const useContacts = create<ContactsState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
books: {},
|
||||
cards: {},
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
principals: [],
|
||||
principalsLoaded: false,
|
||||
recent: [],
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.contacts);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
|
||||
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false });
|
||||
set({ available });
|
||||
if (!available) return;
|
||||
await get().loadBooks();
|
||||
},
|
||||
|
||||
async loadBooks() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
|
||||
const books: Record<Id, AddressBook> = {};
|
||||
for (const b of res.list) books[b.id] = b;
|
||||
set({ books, error: null });
|
||||
} catch (err) {
|
||||
set({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || get().loading) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const cards: Record<Id, ContactCard> = {};
|
||||
let position = 0;
|
||||
const limit = 500;
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const res = await client.chain([
|
||||
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
|
||||
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
|
||||
for (const c of g.list) cards[c.id] = c;
|
||||
position += q.ids.length;
|
||||
if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
|
||||
}
|
||||
set({ cards, loaded: true, loading: false, error: null });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async getCard(id) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return null;
|
||||
const res = await client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: [id] });
|
||||
const c = res.list[0];
|
||||
if (c) set((s) => ({ cards: { ...s.cards, [c.id]: c } }));
|
||||
return c ?? null;
|
||||
},
|
||||
|
||||
search(text) {
|
||||
const q = text.trim().toLowerCase();
|
||||
const all = Object.values(get().cards);
|
||||
const filtered = q
|
||||
? all.filter((c) => {
|
||||
const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
: all;
|
||||
return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
|
||||
},
|
||||
|
||||
async createCard(card, addressBookId) {
|
||||
const accountId = get().accountId!;
|
||||
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: { c: obj } });
|
||||
const err = res.notCreated?.c;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const id = res.created!.c!.id;
|
||||
await get().getCard(id);
|
||||
return id;
|
||||
},
|
||||
|
||||
async updateCard(id, patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().getCard(id);
|
||||
},
|
||||
|
||||
async destroyCards(ids) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: ids });
|
||||
const failed = Object.values(res.notDestroyed ?? {})[0];
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
set((s) => {
|
||||
const cards = { ...s.cards };
|
||||
for (const id of ids) delete cards[id];
|
||||
return { cards };
|
||||
});
|
||||
},
|
||||
|
||||
async createBook(name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||
const err = res.notCreated?.b;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadBooks();
|
||||
return res.created!.b!.id;
|
||||
},
|
||||
|
||||
async updateBook(id, patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadBooks();
|
||||
},
|
||||
|
||||
async destroyBook(id) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadBooks();
|
||||
await get().loadAll();
|
||||
},
|
||||
|
||||
async importVCard(text, addressBookId) {
|
||||
const accountId = get().accountId!;
|
||||
const up = await client.upload(accountId, new Blob([text], { type: "text/vcard" }), { type: "text/vcard" });
|
||||
const parsed = await client.call<{ parsed?: Record<string, ContactCard[] | ContactCard>; notParsable?: Id[] }>("ContactCard/parse", { accountId, blobIds: [up.blobId] });
|
||||
const entry = parsed.parsed?.[up.blobId];
|
||||
const cards: ContactCard[] = entry ? (Array.isArray(entry) ? entry : [entry]) : [];
|
||||
if (!cards.length) throw new Error("No contacts found in file");
|
||||
const create: Record<string, unknown> = {};
|
||||
cards.forEach((c, i) => {
|
||||
const { id: _id, addressBookIds: _ab, ...rest } = c as ContactCard & { id?: Id };
|
||||
create[`c${i}`] = { ...rest, uid: rest.uid || crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
|
||||
});
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().loadAll();
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
async loadPrincipals() {
|
||||
if (get().principalsLoaded) return;
|
||||
const accountId = useSession.getState().accountFor(CAP.principals);
|
||||
if (!accountId || !client.hasCapability(CAP.principals)) {
|
||||
set({ principalsLoaded: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["Principal/query", { accountId, limit: 1000 }, "q"],
|
||||
["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<Principal>;
|
||||
set({ principals: g.list, principalsLoaded: true });
|
||||
} catch {
|
||||
set({ principalsLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
async suggest(query, limit = 8) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const st = get();
|
||||
if (!st.loaded && st.available && !st.loading) void st.loadAll();
|
||||
if (!st.principalsLoaded) void st.loadPrincipals();
|
||||
const out: Suggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (s: Suggestion) => {
|
||||
const k = s.email.toLowerCase();
|
||||
if (!k || seen.has(k)) return;
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
for (const c of Object.values(st.cards)) {
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
add(c);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
addRecent(addrs) {
|
||||
const cur = get().recent;
|
||||
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
|
||||
set({ recent: next });
|
||||
try {
|
||||
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
lookupByEmail(email) {
|
||||
const e = email.toLowerCase();
|
||||
return Object.values(get().cards).find((c) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e));
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("AddressBook")) void get().loadBooks();
|
||||
if (types.has("ContactCard") && get().loaded) void get().loadAll();
|
||||
},
|
||||
}));
|
||||
|
||||
useSession.subscribe((s) => {
|
||||
if (s.status === "authenticated") {
|
||||
const accountId = s.accountFor(CAP.contacts);
|
||||
let recent: EmailAddress[] = [];
|
||||
try {
|
||||
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
useContacts.setState({ recent });
|
||||
} else {
|
||||
useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Harvest recent recipients from Sent when the mail store learns about them.
|
||||
useMail.subscribe((s, prev) => {
|
||||
if (s.emails === prev.emails) return;
|
||||
const sentId = s.roleId("sent");
|
||||
if (!sentId) return;
|
||||
// cheap: only look at newly-added emails in Sent
|
||||
const addrs: EmailAddress[] = [];
|
||||
for (const id of Object.keys(s.emails)) {
|
||||
if (prev.emails[id]) continue;
|
||||
const e = s.emails[id]!;
|
||||
if (e.mailboxIds[sentId]) addrs.push(...(e.to ?? []), ...(e.cc ?? []));
|
||||
}
|
||||
if (addrs.length) useContacts.getState().addRecent(addrs.slice(0, 50));
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, JmapMethodError } from "@/jmap/client";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
|
||||
interface FilesState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
nodes: Record<Id, FileNode>;
|
||||
children: Record<string, Id[]>; // parentId ("root" for null) → ids
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadChildren(parentId: Id | null): Promise<void>;
|
||||
mkdir(parentId: Id | null, name: string): Promise<Id>;
|
||||
upload(parentId: Id | null, files: File[]): Promise<void>;
|
||||
rename(id: Id, name: string): Promise<void>;
|
||||
move(id: Id, parentId: Id | null): Promise<void>;
|
||||
destroy(ids: Id[]): Promise<void>;
|
||||
pathTo(id: Id | null): FileNode[];
|
||||
applyChanges(types: Set<string>): void;
|
||||
}
|
||||
|
||||
const PROPS = ["id", "parentId", "nodeType", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
|
||||
|
||||
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
|
||||
let filtersSupported = true;
|
||||
|
||||
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
|
||||
|
||||
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
|
||||
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
|
||||
const all: FileNode[] = [];
|
||||
let position = 0;
|
||||
for (let guard = 0; guard < 100; guard++) {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"],
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
all.push(...g.list);
|
||||
position += q.ids.length;
|
||||
if (!q.ids.length || (q.total != null && position >= q.total)) break;
|
||||
}
|
||||
const nodes: Record<Id, FileNode> = {};
|
||||
const children: Record<string, Id[]> = { root: [] };
|
||||
for (const n of all) nodes[n.id] = n;
|
||||
for (const n of all.sort(byName)) {
|
||||
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
|
||||
(children[key] ??= []).push(n.id);
|
||||
}
|
||||
for (const n of all) children[n.id] ??= [];
|
||||
set(() => ({ nodes, children, loading: false, error: null }));
|
||||
}
|
||||
|
||||
export const useFiles = create<FilesState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
nodes: {},
|
||||
children: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
uploads: [],
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
|
||||
if (accountId !== get().accountId) set({ accountId, nodes: {}, children: {} });
|
||||
set({ available });
|
||||
},
|
||||
|
||||
async loadChildren(parentId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
if (!filtersSupported) {
|
||||
await loadAllNodes(accountId, set);
|
||||
return;
|
||||
}
|
||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"],
|
||||
]);
|
||||
const q = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
set((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const n of g.list) nodes[n.id] = n;
|
||||
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null };
|
||||
});
|
||||
} catch (err) {
|
||||
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
|
||||
// fetching every node and building the tree client-side.
|
||||
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
|
||||
filtersSupported = false;
|
||||
try {
|
||||
await loadAllNodes(accountId, set);
|
||||
return;
|
||||
} catch (err2) {
|
||||
set({ loading: false, error: (err2 as Error).message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async mkdir(parentId, name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } });
|
||||
const err = res.notCreated?.d;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadChildren(parentId);
|
||||
return res.created!.d!.id;
|
||||
},
|
||||
|
||||
async upload(parentId, files) {
|
||||
const accountId = get().accountId!;
|
||||
for (const f of files) {
|
||||
const id = `${Date.now()}-${f.name}`;
|
||||
set((s) => ({ uploads: [...s.uploads, { id, name: f.name, progress: 0, error: null }] }));
|
||||
try {
|
||||
const up = await client.upload(accountId, f, {
|
||||
type: f.type || "application/octet-stream",
|
||||
onProgress: (l, t) => set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, progress: Math.round((l / t) * 100) } : u)) })),
|
||||
});
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
|
||||
accountId,
|
||||
create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } },
|
||||
});
|
||||
const err = res.notCreated?.f;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
set((s) => ({ uploads: s.uploads.filter((u) => u.id !== id) }));
|
||||
} catch (err) {
|
||||
set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, error: (err as Error).message } : u)) }));
|
||||
}
|
||||
}
|
||||
await get().loadChildren(parentId);
|
||||
},
|
||||
|
||||
async rename(id, name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadChildren(get().nodes[id]?.parentId ?? null);
|
||||
},
|
||||
|
||||
async move(id, parentId) {
|
||||
const accountId = get().accountId!;
|
||||
const from = get().nodes[id]?.parentId ?? null;
|
||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
|
||||
},
|
||||
|
||||
async destroy(ids) {
|
||||
const accountId = get().accountId!;
|
||||
const parents = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
|
||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, destroy: ids, onDestroyRemoveChildren: true });
|
||||
const failed = Object.values(res.notDestroyed ?? {})[0];
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
for (const p of parents) await get().loadChildren(p);
|
||||
},
|
||||
|
||||
pathTo(id) {
|
||||
const out: FileNode[] = [];
|
||||
let cur = id ? get().nodes[id] : undefined;
|
||||
let guard = 0;
|
||||
while (cur && guard++ < 50) {
|
||||
out.unshift(cur);
|
||||
cur = cur.parentId ? get().nodes[cur.parentId] : undefined;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("FileNode")) {
|
||||
for (const key of Object.keys(get().children)) void get().loadChildren(key === "root" ? null : key);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useSession.subscribe((s) => {
|
||||
if (s.status !== "authenticated") useFiles.setState({ accountId: null, nodes: {}, children: {} });
|
||||
});
|
||||
@@ -0,0 +1,922 @@
|
||||
import { create } from "zustand";
|
||||
import { client, chunk, JmapMethodError } from "@/jmap/client";
|
||||
import type {
|
||||
Comparator,
|
||||
Email,
|
||||
EmailFilter,
|
||||
GetResponse,
|
||||
Id,
|
||||
Identity,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
QueryResponse,
|
||||
Quota,
|
||||
SetResponse,
|
||||
Thread,
|
||||
VacationResponse,
|
||||
ChangesResponse,
|
||||
} from "@/jmap/types";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export const LIST_PROPS = [
|
||||
"id",
|
||||
"blobId",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"hasAttachment",
|
||||
"from",
|
||||
"to",
|
||||
"subject",
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"size",
|
||||
"preview",
|
||||
];
|
||||
|
||||
export const FULL_PROPS = [
|
||||
...LIST_PROPS,
|
||||
"messageId",
|
||||
"inReplyTo",
|
||||
"references",
|
||||
"sender",
|
||||
"cc",
|
||||
"bcc",
|
||||
"replyTo",
|
||||
"bodyStructure",
|
||||
"bodyValues",
|
||||
"textBody",
|
||||
"htmlBody",
|
||||
"attachments",
|
||||
"header:List-Unsubscribe:asText",
|
||||
"header:List-Unsubscribe-Post:asText",
|
||||
"header:List-Id:asText",
|
||||
"header:Disposition-Notification-To:asAddresses",
|
||||
"header:X-Priority:asText",
|
||||
"header:Importance:asText",
|
||||
"header:Auto-Submitted:asText",
|
||||
"header:Authentication-Results:asText",
|
||||
];
|
||||
|
||||
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
||||
|
||||
export interface ListQuery {
|
||||
key: string;
|
||||
filter: EmailFilter;
|
||||
sort: Comparator[];
|
||||
collapseThreads: boolean;
|
||||
mailboxId: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ListState extends ListQuery {
|
||||
ids: Id[];
|
||||
total: number;
|
||||
queryState: string | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
export interface MailState {
|
||||
accountId: Id | null;
|
||||
mailboxes: Record<Id, Mailbox>;
|
||||
mailboxState: string | null;
|
||||
mailboxesLoaded: boolean;
|
||||
emails: Record<Id, Email>;
|
||||
fullIds: Record<Id, true>;
|
||||
emailState: string | null;
|
||||
threads: Record<Id, Thread>;
|
||||
identities: Identity[];
|
||||
quotas: Quota[];
|
||||
vacation: VacationResponse | null;
|
||||
list: ListState | null;
|
||||
selected: Record<Id, true>;
|
||||
anchorId: Id | null;
|
||||
loadingThreads: Record<Id, true>;
|
||||
lastSeenInboxEmailIds: Id[] | null;
|
||||
openThreadId: Id | null;
|
||||
setOpenThread(id: Id | null): void;
|
||||
|
||||
setAccount(accountId: Id | null): void;
|
||||
loadMailboxes(): Promise<void>;
|
||||
roleId(role: MailboxRole): Id | null;
|
||||
mailboxPath(id: Id): string;
|
||||
childrenOf(parentId: Id | null): Mailbox[];
|
||||
|
||||
query(q: ListQuery, opts?: { reset?: boolean }): Promise<void>;
|
||||
loadMore(): Promise<void>;
|
||||
refreshList(): Promise<void>;
|
||||
|
||||
getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
|
||||
loadThread(threadId: Id): Promise<Email[]>;
|
||||
threadEmails(threadId: Id): Email[];
|
||||
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
|
||||
|
||||
setKeyword(ids: Id[], keyword: string, value: boolean): Promise<void>;
|
||||
markRead(ids: Id[], read: boolean): Promise<void>;
|
||||
star(ids: Id[], on: boolean): Promise<void>;
|
||||
move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise<void>;
|
||||
addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise<void>;
|
||||
trash(ids: Id[]): Promise<void>;
|
||||
destroy(ids: Id[]): Promise<void>;
|
||||
archive(ids: Id[]): Promise<void>;
|
||||
spam(ids: Id[], isSpam: boolean): Promise<void>;
|
||||
emptyMailbox(mailboxId: Id): Promise<void>;
|
||||
markMailboxRead(mailboxId: Id): Promise<void>;
|
||||
|
||||
createMailbox(name: string, parentId: Id | null): Promise<Id>;
|
||||
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
|
||||
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
|
||||
|
||||
loadIdentities(): Promise<Identity[]>;
|
||||
/** The user's preferred identity (falls back to the first one). */
|
||||
defaultIdentity(): Identity | undefined;
|
||||
setDefaultIdentity(id: Id): void;
|
||||
saveIdentity(id: Id | null, patch: Partial<Identity>): Promise<void>;
|
||||
destroyIdentity(id: Id): Promise<void>;
|
||||
loadVacation(): Promise<void>;
|
||||
saveVacation(patch: Partial<VacationResponse>): Promise<void>;
|
||||
loadQuota(): Promise<void>;
|
||||
|
||||
select(ids: Id[], on: boolean): void;
|
||||
clearSelection(): void;
|
||||
selectAll(): void;
|
||||
setAnchor(id: Id | null): void;
|
||||
|
||||
applyChanges(types: Set<string>): Promise<void>;
|
||||
importEml(blobId: Id, mailboxId: Id, keywords?: Record<string, boolean>): Promise<Id | null>;
|
||||
}
|
||||
|
||||
function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: boolean }): string {
|
||||
return JSON.stringify([q.filter, q.sort, q.collapseThreads]);
|
||||
}
|
||||
|
||||
export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }];
|
||||
|
||||
export const useMail = create<MailState>((set, get) => ({
|
||||
accountId: null,
|
||||
mailboxes: {},
|
||||
mailboxState: null,
|
||||
mailboxesLoaded: false,
|
||||
emails: {},
|
||||
fullIds: {},
|
||||
emailState: null,
|
||||
threads: {},
|
||||
identities: [],
|
||||
quotas: [],
|
||||
vacation: null,
|
||||
list: null,
|
||||
selected: {},
|
||||
anchorId: null,
|
||||
loadingThreads: {},
|
||||
lastSeenInboxEmailIds: null,
|
||||
openThreadId: null,
|
||||
|
||||
setOpenThread(id) {
|
||||
set({ openThreadId: id });
|
||||
},
|
||||
|
||||
setAccount(accountId) {
|
||||
if (accountId === get().accountId) return;
|
||||
set({
|
||||
accountId,
|
||||
mailboxes: {},
|
||||
mailboxState: null,
|
||||
mailboxesLoaded: false,
|
||||
emails: {},
|
||||
fullIds: {},
|
||||
emailState: null,
|
||||
threads: {},
|
||||
identities: [],
|
||||
quotas: [],
|
||||
vacation: null,
|
||||
list: null,
|
||||
selected: {},
|
||||
anchorId: null,
|
||||
lastSeenInboxEmailIds: null,
|
||||
});
|
||||
},
|
||||
|
||||
async loadMailboxes() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null });
|
||||
const mailboxes: Record<Id, Mailbox> = {};
|
||||
for (const m of res.list) mailboxes[m.id] = m;
|
||||
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
|
||||
},
|
||||
|
||||
roleId(role) {
|
||||
for (const m of Object.values(get().mailboxes)) if (m.role === role) return m.id;
|
||||
return null;
|
||||
},
|
||||
|
||||
mailboxPath(id) {
|
||||
const mbs = get().mailboxes;
|
||||
const parts: string[] = [];
|
||||
let cur: Mailbox | undefined = mbs[id];
|
||||
let guard = 0;
|
||||
while (cur && guard++ < 20) {
|
||||
parts.unshift(cur.role === "inbox" ? "INBOX" : cur.name);
|
||||
cur = cur.parentId ? mbs[cur.parentId] : undefined;
|
||||
}
|
||||
return parts.join("/");
|
||||
},
|
||||
|
||||
childrenOf(parentId) {
|
||||
return Object.values(get().mailboxes)
|
||||
.filter((m) => (m.parentId ?? null) === parentId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
},
|
||||
|
||||
async query(q, opts = {}) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
const key = listKey(q);
|
||||
const cur = get().list;
|
||||
const reuse = cur && cur.key === key && !opts.reset;
|
||||
if (reuse && cur.ids.length && !cur.error) {
|
||||
// Already showing; just refresh in background.
|
||||
void get().refreshList();
|
||||
return;
|
||||
}
|
||||
set({
|
||||
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
|
||||
selected: {},
|
||||
anchorId: null,
|
||||
});
|
||||
try {
|
||||
const { ids, total, queryState } = await runQuery(accountId, q, 0, settings().pageSize);
|
||||
if (get().list?.key !== key) return;
|
||||
set((s) => ({ list: s.list ? { ...s.list, ids, total, queryState, loading: false, exhausted: ids.length >= total } : s.list }));
|
||||
} catch (err) {
|
||||
if (get().list?.key !== key) return;
|
||||
set((s) => ({ list: s.list ? { ...s.list, loading: false, error: (err as Error).message } : s.list }));
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
const accountId = get().accountId;
|
||||
const l = get().list;
|
||||
if (!accountId || !l || l.loading || l.loadingMore || l.exhausted) return;
|
||||
set({ list: { ...l, loadingMore: true } });
|
||||
try {
|
||||
const { ids, total, queryState } = await runQuery(accountId, l, l.ids.length, settings().pageSize);
|
||||
const cur = get().list;
|
||||
if (!cur || cur.key !== l.key) return;
|
||||
const merged = [...cur.ids];
|
||||
const seen = new Set(merged);
|
||||
for (const id of ids) if (!seen.has(id)) merged.push(id);
|
||||
set({ list: { ...cur, ids: merged, total, queryState, loadingMore: false, exhausted: ids.length === 0 || merged.length >= total } });
|
||||
} catch (err) {
|
||||
const cur = get().list;
|
||||
if (cur && cur.key === l.key) set({ list: { ...cur, loadingMore: false, error: (err as Error).message } });
|
||||
}
|
||||
},
|
||||
|
||||
async refreshList() {
|
||||
const accountId = get().accountId;
|
||||
const l = get().list;
|
||||
if (!accountId || !l) return;
|
||||
try {
|
||||
const limit = Math.max(settings().pageSize, l.ids.length);
|
||||
const { ids, total, queryState } = await runQuery(accountId, l, 0, limit);
|
||||
const cur = get().list;
|
||||
if (!cur || cur.key !== l.key) return;
|
||||
set({ list: { ...cur, ids, total, queryState, loading: false, error: null, exhausted: ids.length >= total } });
|
||||
} catch {
|
||||
/* keep old list */
|
||||
}
|
||||
},
|
||||
|
||||
async getEmails(ids, full = false) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return [];
|
||||
const { emails, fullIds } = get();
|
||||
const missing = ids.filter((id) => !emails[id] || (full && !fullIds[id]));
|
||||
if (missing.length) {
|
||||
const results = await Promise.all(
|
||||
chunk(missing, client.maxObjectsInGet).map((part) =>
|
||||
client.call<GetResponse<Email>>("Email/get", {
|
||||
accountId,
|
||||
ids: part,
|
||||
properties: full ? FULL_PROPS : LIST_PROPS,
|
||||
...(full ? { fetchHTMLBodyValues: true, fetchTextBodyValues: true, maxBodyValueBytes: 2 * 1024 * 1024, bodyProperties: BODY_PROPS } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
const nextFull = { ...s.fullIds };
|
||||
let state = s.emailState;
|
||||
for (const r of results) {
|
||||
state = r.state;
|
||||
for (const e of r.list) {
|
||||
next[e.id] = { ...next[e.id], ...e };
|
||||
if (full) nextFull[e.id] = true;
|
||||
}
|
||||
}
|
||||
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
|
||||
});
|
||||
}
|
||||
const now = get().emails;
|
||||
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e));
|
||||
},
|
||||
|
||||
async loadThread(threadId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return [];
|
||||
set((s) => ({ loadingThreads: { ...s.loadingThreads, [threadId]: true } }));
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["Thread/get", { accountId, ids: [threadId] }, "t"],
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
accountId,
|
||||
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
|
||||
properties: FULL_PROPS,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
maxBodyValueBytes: 2 * 1024 * 1024,
|
||||
bodyProperties: BODY_PROPS,
|
||||
},
|
||||
"e",
|
||||
],
|
||||
]);
|
||||
const thread = (res.get("t")?.[0] as unknown as GetResponse<Thread>).list[0];
|
||||
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
|
||||
if (!thread) return [];
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
const nextFull = { ...s.fullIds };
|
||||
for (const e of emailsRes.list) {
|
||||
next[e.id] = { ...next[e.id], ...e };
|
||||
nextFull[e.id] = true;
|
||||
}
|
||||
const { [threadId]: _drop, ...rest } = s.loadingThreads;
|
||||
return { emails: next, fullIds: nextFull, threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
|
||||
});
|
||||
return get().threadEmails(threadId);
|
||||
} catch (err) {
|
||||
set((s) => {
|
||||
const { [threadId]: _drop, ...rest } = s.loadingThreads;
|
||||
return { loadingThreads: rest };
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
threadEmails(threadId) {
|
||||
const { threads, emails } = get();
|
||||
const t = threads[threadId];
|
||||
if (!t) return [];
|
||||
return t.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e));
|
||||
},
|
||||
|
||||
threadIdsIn(threadId, mailboxId) {
|
||||
const t = get().threads[threadId];
|
||||
if (!t) return [];
|
||||
if (!mailboxId) return [...t.emailIds];
|
||||
const { emails } = get();
|
||||
return t.emailIds.filter((id) => emails[id]?.mailboxIds[mailboxId]);
|
||||
},
|
||||
|
||||
async setKeyword(ids, keyword, value) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
// optimistic
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const id of ids) {
|
||||
const e = next[id];
|
||||
if (!e) continue;
|
||||
const kw = { ...e.keywords };
|
||||
if (value) kw[keyword] = true;
|
||||
else delete kw[keyword];
|
||||
next[id] = { ...e, keywords: kw };
|
||||
}
|
||||
return { emails: next };
|
||||
});
|
||||
const update: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) update[id] = { [`keywords/${keyword}`]: value ? true : null };
|
||||
try {
|
||||
await setEmails(accountId, update);
|
||||
} catch (err) {
|
||||
toast.error(`Could not update: ${(err as Error).message}`);
|
||||
void get().getEmails(ids);
|
||||
}
|
||||
},
|
||||
|
||||
markRead(ids, read) {
|
||||
return get().setKeyword(ids, "$seen", read);
|
||||
},
|
||||
|
||||
star(ids, on) {
|
||||
return get().setKeyword(ids, "$flagged", on);
|
||||
},
|
||||
|
||||
async move(ids, toMailboxId, opts = {}) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
const { emails, mailboxes } = get();
|
||||
const prev: Record<Id, Record<Id, boolean>> = {};
|
||||
const update: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) {
|
||||
const e = emails[id];
|
||||
prev[id] = e?.mailboxIds ?? {};
|
||||
update[id] = { mailboxIds: { [toMailboxId]: true } };
|
||||
}
|
||||
// optimistic
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: { [toMailboxId]: true } };
|
||||
return { emails: next, selected: {} };
|
||||
});
|
||||
removeFromList(ids, set, get, toMailboxId);
|
||||
try {
|
||||
await setEmails(accountId, update);
|
||||
if (!opts.silent) {
|
||||
const name = opts.label ?? mailboxes[toMailboxId]?.name ?? "folder";
|
||||
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: async () => {
|
||||
const undo: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) undo[id] = { mailboxIds: prev[id] };
|
||||
await setEmails(accountId, undo);
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: prev[id]! };
|
||||
return { emails: next };
|
||||
});
|
||||
void get().refreshList();
|
||||
void get().loadMailboxes();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
void get().loadMailboxes();
|
||||
} catch (err) {
|
||||
toast.error(`Move failed: ${(err as Error).message}`);
|
||||
void get().getEmails(ids);
|
||||
void get().refreshList();
|
||||
}
|
||||
},
|
||||
|
||||
async addToMailbox(ids, mailboxId, add) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
const update: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) update[id] = { [`mailboxIds/${mailboxId}`]: add ? true : null };
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const id of ids) {
|
||||
const e = next[id];
|
||||
if (!e) continue;
|
||||
const mb = { ...e.mailboxIds };
|
||||
if (add) mb[mailboxId] = true;
|
||||
else delete mb[mailboxId];
|
||||
next[id] = { ...e, mailboxIds: mb };
|
||||
}
|
||||
return { emails: next };
|
||||
});
|
||||
try {
|
||||
await setEmails(accountId, update);
|
||||
void get().loadMailboxes();
|
||||
} catch (err) {
|
||||
toast.error(`Could not update labels: ${(err as Error).message}`);
|
||||
void get().getEmails(ids);
|
||||
}
|
||||
},
|
||||
|
||||
async trash(ids) {
|
||||
const { roleId, emails } = get();
|
||||
const trashId = roleId("trash");
|
||||
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
|
||||
const toMove = ids.filter((id) => !inTrash.includes(id));
|
||||
if (inTrash.length) await get().destroy(inTrash);
|
||||
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Trash" });
|
||||
else if (toMove.length) await get().destroy(toMove);
|
||||
},
|
||||
|
||||
async destroy(ids) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
removeFromList(ids, set, get, null);
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const id of ids) delete next[id];
|
||||
return { emails: next, selected: {} };
|
||||
});
|
||||
try {
|
||||
const res = await client.call<SetResponse>("Email/set", { accountId, destroy: ids });
|
||||
const failed = Object.keys(res.notDestroyed ?? {});
|
||||
if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`);
|
||||
else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`);
|
||||
void get().loadMailboxes();
|
||||
} catch (err) {
|
||||
toast.error(`Delete failed: ${(err as Error).message}`);
|
||||
void get().refreshList();
|
||||
}
|
||||
},
|
||||
|
||||
async archive(ids) {
|
||||
const archiveId = get().roleId("archive") ?? get().roleId("all");
|
||||
if (!archiveId) {
|
||||
toast.error("No Archive folder found. Create one named “Archive” first.");
|
||||
return;
|
||||
}
|
||||
await get().move(ids, archiveId, { label: "Archive" });
|
||||
},
|
||||
|
||||
async spam(ids, isSpam) {
|
||||
const { roleId } = get();
|
||||
const target = isSpam ? roleId("junk") : roleId("inbox");
|
||||
if (!target) return;
|
||||
const kw: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of ids) kw[id] = { "keywords/$junk": isSpam ? true : null, "keywords/$notjunk": isSpam ? null : true };
|
||||
const accountId = get().accountId!;
|
||||
try {
|
||||
await setEmails(accountId, kw);
|
||||
} catch {
|
||||
/* keyword may be rejected; still move */
|
||||
}
|
||||
await get().move(ids, target, { label: isSpam ? "Spam" : "Inbox" });
|
||||
},
|
||||
|
||||
async emptyMailbox(mailboxId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: 5000 }, "q"],
|
||||
["Email/set", { accountId, "#destroy": { resultOf: "q", name: "Email/query", path: "/ids" } }, "s"],
|
||||
]);
|
||||
const s = res.get("s")?.[0] as unknown as SetResponse;
|
||||
const n = s.destroyed?.length ?? 0;
|
||||
toast.show(`Deleted ${n} message${n === 1 ? "" : "s"}`);
|
||||
set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null });
|
||||
void get().loadMailboxes();
|
||||
void get().refreshList();
|
||||
} catch (err) {
|
||||
toast.error(`Could not empty folder: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async markMailboxRead(mailboxId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["Email/query", { accountId, filter: { inMailbox: mailboxId, notKeyword: "$seen" }, limit: 5000 }, "q"],
|
||||
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: ["id"] }, "g"],
|
||||
]);
|
||||
const ids = ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
|
||||
if (ids.length) await get().markRead(ids, true);
|
||||
void get().loadMailboxes();
|
||||
} catch (err) {
|
||||
toast.error(`Could not mark as read: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async createMailbox(name, parentId) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } });
|
||||
const err = res.notCreated?.n;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadMailboxes();
|
||||
return res.created!.n!.id;
|
||||
},
|
||||
|
||||
async updateMailbox(id, patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadMailboxes();
|
||||
},
|
||||
|
||||
async destroyMailbox(id, removeEmails = true) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadMailboxes();
|
||||
},
|
||||
|
||||
async loadIdentities() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return [];
|
||||
const res = await client.call<GetResponse<Identity>>("Identity/get", { accountId, ids: null });
|
||||
set({ identities: sortIdentities(res.list, accountId) });
|
||||
// Long signatures live in Files; swap the stored marker for the full HTML.
|
||||
const { markerOf } = await import("@/lib/signatureHtml");
|
||||
const pending = res.list.filter((i) => markerOf(i.htmlSignature));
|
||||
if (pending.length) {
|
||||
const { loadStoredSignature } = await import("@/lib/signatureImages");
|
||||
const full = await Promise.all(pending.map(async (i) => { const m = markerOf(i.htmlSignature)!; try { return [i.id, await loadStoredSignature(m.blobId, m.type)] as const; } catch { return [i.id, null] as const; } }));
|
||||
if (get().accountId === accountId) {
|
||||
set((s) => ({ identities: s.identities.map((i) => { const f = full.find(([id]) => id === i.id)?.[1]; return f ? { ...i, htmlSignature: f } : i; }) }));
|
||||
}
|
||||
}
|
||||
return get().identities;
|
||||
},
|
||||
|
||||
defaultIdentity() {
|
||||
const { identities, accountId } = get();
|
||||
const pref = accountId ? settings().defaultIdentityByAccount[accountId] : undefined;
|
||||
return identities.find((i) => i.id === pref) ?? identities[0];
|
||||
},
|
||||
|
||||
setDefaultIdentity(id) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
useSettings.getState().update({ defaultIdentityByAccount: { ...settings().defaultIdentityByAccount, [accountId]: id } });
|
||||
set({ identities: sortIdentities(get().identities, accountId) });
|
||||
},
|
||||
|
||||
async saveIdentity(id, patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = id
|
||||
? await client.call<SetResponse<Identity>>("Identity/set", { accountId, update: { [id]: patch } })
|
||||
: await client.call<SetResponse<Identity>>("Identity/set", { accountId, create: { n: patch } });
|
||||
const err = id ? res.notUpdated?.[id] : res.notCreated?.n;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadIdentities();
|
||||
},
|
||||
|
||||
async destroyIdentity(id) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("Identity/set", { accountId, destroy: [id] });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadIdentities();
|
||||
},
|
||||
|
||||
async loadVacation() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<VacationResponse>>("VacationResponse/get", { accountId, ids: null });
|
||||
set({ vacation: res.list[0] ?? null });
|
||||
} catch {
|
||||
set({ vacation: null });
|
||||
}
|
||||
},
|
||||
|
||||
async saveVacation(patch) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("VacationResponse/set", { accountId, update: { singleton: patch } });
|
||||
const err = res.notUpdated?.singleton;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().loadVacation();
|
||||
},
|
||||
|
||||
async loadQuota() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !client.hasCapability("urn:ietf:params:jmap:quota")) return;
|
||||
try {
|
||||
const res = await client.call<GetResponse<Quota>>("Quota/get", { accountId, ids: null });
|
||||
set({ quotas: res.list });
|
||||
} catch {
|
||||
set({ quotas: [] });
|
||||
}
|
||||
},
|
||||
|
||||
select(ids, on) {
|
||||
set((s) => {
|
||||
const next = { ...s.selected };
|
||||
for (const id of ids) {
|
||||
if (on) next[id] = true;
|
||||
else delete next[id];
|
||||
}
|
||||
return { selected: next };
|
||||
});
|
||||
},
|
||||
clearSelection() {
|
||||
set({ selected: {} });
|
||||
},
|
||||
selectAll() {
|
||||
const l = get().list;
|
||||
if (!l) return;
|
||||
const next: Record<Id, true> = {};
|
||||
for (const id of l.ids) next[id] = true;
|
||||
set({ selected: next });
|
||||
},
|
||||
setAnchor(id) {
|
||||
set({ anchorId: id });
|
||||
},
|
||||
|
||||
async applyChanges(types) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
if (types.has("Mailbox")) void get().loadMailboxes();
|
||||
if (types.has("Email")) {
|
||||
const state = get().emailState;
|
||||
if (state) {
|
||||
try {
|
||||
let since = state;
|
||||
let guard = 0;
|
||||
const updated = new Set<Id>();
|
||||
const created = new Set<Id>();
|
||||
const destroyed = new Set<Id>();
|
||||
// Page through Email/changes.
|
||||
while (guard++ < 10) {
|
||||
const ch = await client.call<ChangesResponse>("Email/changes", { accountId, sinceState: since, maxChanges: 500 });
|
||||
ch.created.forEach((id) => created.add(id));
|
||||
ch.updated.forEach((id) => updated.add(id));
|
||||
ch.destroyed.forEach((id) => destroyed.add(id));
|
||||
since = ch.newState;
|
||||
if (!ch.hasMoreChanges) break;
|
||||
}
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
const nextFull = { ...s.fullIds };
|
||||
for (const id of destroyed) {
|
||||
delete next[id];
|
||||
delete nextFull[id];
|
||||
}
|
||||
// Drop cached versions of updated emails so they're refetched lazily.
|
||||
for (const id of updated) {
|
||||
if (next[id] && nextFull[id]) delete nextFull[id];
|
||||
}
|
||||
return { emails: next, fullIds: nextFull, emailState: since };
|
||||
});
|
||||
// Refresh the list-level props of updated/cached emails.
|
||||
const cached = [...updated].filter((id) => get().emails[id]);
|
||||
if (cached.length) {
|
||||
const results = await Promise.all(
|
||||
chunk(cached, client.maxObjectsInGet).map((part) => client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: LIST_PROPS })),
|
||||
);
|
||||
set((s) => {
|
||||
const next = { ...s.emails };
|
||||
for (const r of results) for (const e of r.list) next[e.id] = { ...next[e.id], ...e };
|
||||
return { emails: next };
|
||||
});
|
||||
}
|
||||
if (created.size) await notifyNewMail([...created], get);
|
||||
} catch (err) {
|
||||
if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") {
|
||||
set({ emailState: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
void get().refreshList();
|
||||
void get().loadMailboxes();
|
||||
}
|
||||
if (types.has("Thread") || types.has("Email")) {
|
||||
const open = get().openThreadId;
|
||||
if (open) void get().loadThread(open).catch(() => undefined);
|
||||
}
|
||||
if (types.has("Identity")) void get().loadIdentities();
|
||||
if (types.has("VacationResponse")) void get().loadVacation();
|
||||
if (types.has("Quota")) void get().loadQuota();
|
||||
},
|
||||
|
||||
async importEml(blobId, mailboxId, keywords = {}) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return null;
|
||||
const res = await client.call<{ created?: Record<string, Email>; notCreated?: Record<string, { type: string; description?: string }> }>("Email/import", {
|
||||
accountId,
|
||||
emails: { i: { blobId, mailboxIds: { [mailboxId]: true }, keywords } },
|
||||
});
|
||||
if (res.notCreated?.i) throw new Error(res.notCreated.i.description ?? res.notCreated.i.type);
|
||||
void get().refreshList();
|
||||
void get().loadMailboxes();
|
||||
return res.created?.i?.id ?? null;
|
||||
},
|
||||
}));
|
||||
|
||||
function sortIdentities(list: Identity[], accountId: Id): Identity[] {
|
||||
const pref = settings().defaultIdentityByAccount[accountId];
|
||||
return [...list].sort((a, b) => (a.id === pref ? -1 : b.id === pref ? 1 : a.email.localeCompare(b.email)));
|
||||
}
|
||||
|
||||
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
|
||||
const calls: Array<[string, Record<string, unknown>, string]> = [
|
||||
["Email/query", { accountId, filter: q.filter, sort: q.sort, collapseThreads: q.collapseThreads, position, limit, calculateTotal: true }, "q"],
|
||||
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
|
||||
];
|
||||
if (q.collapseThreads) {
|
||||
calls.push(["Thread/get", { accountId, "#ids": { resultOf: "e", name: "Email/get", path: "/list/*/threadId" } }, "t"]);
|
||||
calls.push(["Email/get", { accountId, "#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" }, properties: LIST_PROPS }, "te"]);
|
||||
}
|
||||
const res = await client.chain(calls);
|
||||
const query = res.get("q")?.[0] as unknown as QueryResponse;
|
||||
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
|
||||
const threadsRes = res.get("t")?.[0] as unknown as GetResponse<Thread> | undefined;
|
||||
const threadEmails = res.get("te")?.[0] as unknown as GetResponse<Email> | undefined;
|
||||
useMail.setState((s) => {
|
||||
const emails = { ...s.emails };
|
||||
for (const e of emailsRes.list) emails[e.id] = { ...emails[e.id], ...e };
|
||||
for (const e of threadEmails?.list ?? []) emails[e.id] = { ...emails[e.id], ...e };
|
||||
const threads = { ...s.threads };
|
||||
for (const t of threadsRes?.list ?? []) threads[t.id] = t;
|
||||
return { emails, threads, emailState: s.emailState ?? emailsRes.state };
|
||||
});
|
||||
return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState };
|
||||
}
|
||||
|
||||
async function setEmails(accountId: Id, update: Record<Id, Record<string, unknown>>) {
|
||||
const ids = Object.keys(update);
|
||||
for (const part of chunk(ids, 400)) {
|
||||
const sub: Record<Id, Record<string, unknown>> = {};
|
||||
for (const id of part) sub[id] = update[id]!;
|
||||
const res = await client.call<SetResponse>("Email/set", { accountId, update: sub });
|
||||
const failed = Object.entries(res.notUpdated ?? {});
|
||||
if (failed.length) {
|
||||
const [, err] = failed[0]!;
|
||||
throw new Error(`${err.type}${err.description ? `: ${err.description}` : ""}${failed.length > 1 ? ` (+${failed.length - 1} more)` : ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove given email ids (and threads they represent) from the current list optimistically. */
|
||||
function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial<MailState>) => void, get: () => MailState, targetMailboxId: Id | null) {
|
||||
const l = get().list;
|
||||
if (!l) return;
|
||||
// If the list is showing the mailbox we're moving into, don't remove.
|
||||
if (targetMailboxId && l.mailboxId === targetMailboxId) return;
|
||||
const idSet = new Set(ids);
|
||||
const { emails, threads } = get();
|
||||
const removeRow = (rowId: Id): boolean => {
|
||||
if (idSet.has(rowId)) return true;
|
||||
if (!l.collapseThreads) return false;
|
||||
const e = emails[rowId];
|
||||
if (!e) return false;
|
||||
const t = threads[e.threadId];
|
||||
if (!t) return false;
|
||||
// Row goes away if no email of the thread remains in this mailbox after the move.
|
||||
if (l.mailboxId) {
|
||||
const remaining = t.emailIds.filter((id) => !idSet.has(id) && emails[id]?.mailboxIds[l.mailboxId!]);
|
||||
return remaining.length === 0;
|
||||
}
|
||||
return t.emailIds.every((id) => idSet.has(id));
|
||||
};
|
||||
const nextIds = l.ids.filter((id) => !removeRow(id));
|
||||
if (nextIds.length !== l.ids.length) {
|
||||
set((s) => ({ list: s.list ? { ...s.list, ids: nextIds, total: Math.max(0, s.list.total - (l.ids.length - nextIds.length)) } : s.list }));
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
const s = settings();
|
||||
const inbox = get().roleId("inbox");
|
||||
if (!inbox) return;
|
||||
const emails = await get().getEmails(created);
|
||||
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||
if (!fresh.length) return;
|
||||
const { showNotification, playNewMailSound } = await import("@/lib/notify");
|
||||
if (s.notificationSound) playNewMailSound();
|
||||
if (s.desktopNotifications) {
|
||||
for (const e of fresh.slice(0, 3)) {
|
||||
const from = e.from?.[0];
|
||||
showNotification(from?.name || from?.email || "New message", {
|
||||
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
|
||||
tag: e.id,
|
||||
onClick: () => {
|
||||
window.location.hash = "";
|
||||
window.history.pushState({}, "", `/mail/${inbox}/${e.threadId}`);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the store bound to the selected account. */
|
||||
useSession.subscribe((s) => {
|
||||
useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null);
|
||||
});
|
||||
|
||||
export function mailboxIcon(role: MailboxRole): string {
|
||||
switch (role) {
|
||||
case "inbox":
|
||||
return "inbox";
|
||||
case "drafts":
|
||||
return "file";
|
||||
case "sent":
|
||||
return "send";
|
||||
case "trash":
|
||||
return "trash";
|
||||
case "junk":
|
||||
return "alert";
|
||||
case "archive":
|
||||
return "archive";
|
||||
case "all":
|
||||
return "mail";
|
||||
case "flagged":
|
||||
return "star";
|
||||
case "important":
|
||||
return "tag";
|
||||
default:
|
||||
return "folder";
|
||||
}
|
||||
}
|
||||
|
||||
export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
|
||||
@@ -0,0 +1,100 @@
|
||||
import { create } from "zustand";
|
||||
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
||||
import type { Id, JmapSession } from "@/jmap/types";
|
||||
import { push } from "@/jmap/push";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
interface SessionState {
|
||||
status: AuthStatus;
|
||||
session: JmapSession | null;
|
||||
/** Selected mail account (defaults to primary). */
|
||||
accountId: Id | null;
|
||||
error: string | null;
|
||||
pushConnected: boolean;
|
||||
bootstrap(): Promise<void>;
|
||||
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
|
||||
accountFor(cap: string): Id | null;
|
||||
}
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
status: "loading",
|
||||
session: null,
|
||||
accountId: null,
|
||||
error: null,
|
||||
pushConnected: false,
|
||||
|
||||
async bootstrap() {
|
||||
try {
|
||||
const s = await apiFetch<JmapSession>("/api/auth/session");
|
||||
applySession(s, set);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null });
|
||||
else set({ status: "anonymous", error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async login(username, password, totp, remember) {
|
||||
set({ error: null });
|
||||
const s = await apiFetch<JmapSession>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, totp: totp || undefined, remember }),
|
||||
});
|
||||
applySession(s, set);
|
||||
},
|
||||
|
||||
async logout() {
|
||||
push.stop();
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null });
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
||||
client.session = s;
|
||||
set({ session: s });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
setAccount(id) {
|
||||
set({ accountId: id });
|
||||
},
|
||||
|
||||
accountFor(cap) {
|
||||
const s = get().session;
|
||||
if (!s) return null;
|
||||
const selected = get().accountId;
|
||||
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
|
||||
return s.primaryAccounts[cap] ?? selected ?? null;
|
||||
},
|
||||
}));
|
||||
|
||||
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
client.session = s;
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, error: null });
|
||||
}
|
||||
|
||||
client.onUnauthenticated(() => {
|
||||
push.stop();
|
||||
client.session = null;
|
||||
useSession.setState({ status: "anonymous", session: null, accountId: null });
|
||||
});
|
||||
|
||||
push.onConnection((connected) => useSession.setState({ pushConnected: connected }));
|
||||
|
||||
export function hasCap(cap: string): boolean {
|
||||
return client.hasCapability(cap);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { create } from "zustand";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
export type Density = "comfortable" | "cozy" | "compact";
|
||||
export type ReadingPane = "right" | "bottom" | "off";
|
||||
export type ImagePolicy = "ask" | "always" | "contacts";
|
||||
export type ComposeFormat = "html" | "text";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme: Theme;
|
||||
accent: string;
|
||||
density: Density;
|
||||
readingPane: ReadingPane;
|
||||
conversationMode: boolean;
|
||||
showPreview: boolean;
|
||||
showAvatars: boolean;
|
||||
pageSize: number;
|
||||
markReadDelay: number; // seconds; -1 = never auto
|
||||
imagePolicy: ImagePolicy;
|
||||
undoSendSeconds: number;
|
||||
composeFormat: ComposeFormat;
|
||||
replyAllDefault: boolean;
|
||||
signatureAboveQuote: boolean;
|
||||
includeQuote: boolean;
|
||||
requestReadReceipt: boolean;
|
||||
confirmDelete: boolean;
|
||||
desktopNotifications: boolean;
|
||||
notificationSound: boolean;
|
||||
attachmentReminder: boolean;
|
||||
weekStart: 0 | 1 | 6;
|
||||
timeFormat: "12" | "24" | "auto";
|
||||
calendarDefaultView: "month" | "week" | "day" | "agenda";
|
||||
workDayStart: number;
|
||||
workDayEnd: number;
|
||||
defaultEventDuration: number; // minutes
|
||||
defaultAlertMinutes: number;
|
||||
timeZone: string | null; // null = browser
|
||||
language: string;
|
||||
labelsSidebar: boolean;
|
||||
fontSize: "small" | "medium" | "large";
|
||||
templates: Template[];
|
||||
labels: Array<{ keyword: string; name: string; color: string }>;
|
||||
sidebarCollapsed: boolean;
|
||||
showHiddenFolders: boolean;
|
||||
trustedImageSenders: string[];
|
||||
archiveOnReply: boolean;
|
||||
autoAdvance: "newer" | "older" | "list";
|
||||
spellcheck: boolean;
|
||||
sendAndArchive: boolean;
|
||||
/** Width (px) of the message list when the reading pane is on the right. */
|
||||
listPaneWidth: number;
|
||||
/** Height (px) of the message list when the reading pane is below. */
|
||||
listPaneHeight: number;
|
||||
/** Outlook-style colour categories for calendar events. */
|
||||
eventCategories: Array<{ name: string; color: string }>;
|
||||
/** Default sending identity per account (JMAP has no such flag). */
|
||||
defaultIdentityByAccount: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
theme: "system",
|
||||
accent: "teal",
|
||||
density: "cozy",
|
||||
readingPane: "right",
|
||||
conversationMode: true,
|
||||
showPreview: true,
|
||||
showAvatars: true,
|
||||
pageSize: 50,
|
||||
markReadDelay: 0,
|
||||
imagePolicy: "ask",
|
||||
undoSendSeconds: 8,
|
||||
composeFormat: "html",
|
||||
replyAllDefault: false,
|
||||
signatureAboveQuote: true,
|
||||
includeQuote: true,
|
||||
requestReadReceipt: false,
|
||||
confirmDelete: false,
|
||||
desktopNotifications: false,
|
||||
notificationSound: false,
|
||||
attachmentReminder: true,
|
||||
weekStart: 1,
|
||||
timeFormat: "auto",
|
||||
calendarDefaultView: "week",
|
||||
workDayStart: 8,
|
||||
workDayEnd: 18,
|
||||
defaultEventDuration: 60,
|
||||
defaultAlertMinutes: 10,
|
||||
timeZone: null,
|
||||
language: "en",
|
||||
labelsSidebar: true,
|
||||
fontSize: "medium",
|
||||
templates: [],
|
||||
labels: [],
|
||||
sidebarCollapsed: false,
|
||||
showHiddenFolders: false,
|
||||
trustedImageSenders: [],
|
||||
archiveOnReply: false,
|
||||
autoAdvance: "list",
|
||||
spellcheck: true,
|
||||
sendAndArchive: false,
|
||||
listPaneWidth: 520,
|
||||
listPaneHeight: 340,
|
||||
eventCategories: [
|
||||
{ name: "Important", color: "#dc2626" },
|
||||
{ name: "Work", color: "#2563eb" },
|
||||
{ name: "Personal", color: "#16a34a" },
|
||||
{ name: "Travel", color: "#ea580c" },
|
||||
{ name: "Family", color: "#9333ea" },
|
||||
],
|
||||
defaultIdentityByAccount: {},
|
||||
};
|
||||
|
||||
interface SettingsState {
|
||||
settings: Settings;
|
||||
update(patch: Partial<Settings>): void;
|
||||
reset(): void;
|
||||
exportJson(): string;
|
||||
importJson(json: string): boolean;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
settings: loadJson<Settings>("settings", DEFAULT_SETTINGS),
|
||||
update(patch) {
|
||||
const settings = { ...get().settings, ...patch };
|
||||
saveJson("settings", settings);
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
},
|
||||
reset() {
|
||||
saveJson("settings", DEFAULT_SETTINGS);
|
||||
set({ settings: DEFAULT_SETTINGS });
|
||||
applyTheme(DEFAULT_SETTINGS);
|
||||
},
|
||||
exportJson() {
|
||||
return JSON.stringify(get().settings, null, 2);
|
||||
},
|
||||
importJson(json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Partial<Settings>;
|
||||
get().update(parsed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||
const root = document.documentElement;
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
|
||||
root.dataset.theme = dark ? "dark" : "light";
|
||||
root.dataset.density = s.density;
|
||||
root.dataset.accent = s.accent;
|
||||
root.dataset.fontsize = s.fontSize;
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||
if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
applyTheme();
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
|
||||
}
|
||||
|
||||
export const settings = () => useSettings.getState().settings;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
|
||||
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export const IHASMAIL_SCRIPT = "ihasmail";
|
||||
|
||||
interface SieveState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
scripts: SieveScript[];
|
||||
/** Content of each script by id. */
|
||||
contents: Record<Id, string>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
init(): Promise<void>;
|
||||
load(): Promise<void>;
|
||||
getContent(id: Id): Promise<string>;
|
||||
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
|
||||
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string };
|
||||
saveRules(rules: SieveRule[]): Promise<void>;
|
||||
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
|
||||
activate(id: Id | null): Promise<void>;
|
||||
destroy(id: Id): Promise<void>;
|
||||
validate(content: string): Promise<string | null>;
|
||||
applyChanges(types: Set<string>): void;
|
||||
}
|
||||
|
||||
export const useSieve = create<SieveState>((set, get) => ({
|
||||
accountId: null,
|
||||
available: false,
|
||||
scripts: [],
|
||||
contents: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.sieve);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.sieve));
|
||||
set({ accountId, available });
|
||||
if (available) await get().load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
|
||||
set({ scripts: res.list, loading: false, error: null });
|
||||
// Preload contents
|
||||
const contents: Record<Id, string> = {};
|
||||
await Promise.all(
|
||||
res.list.map(async (s) => {
|
||||
try {
|
||||
contents[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
|
||||
} catch {
|
||||
contents[s.id] = "";
|
||||
}
|
||||
}),
|
||||
);
|
||||
set({ contents });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
async getContent(id) {
|
||||
const cached = get().contents[id];
|
||||
if (cached != null) return cached;
|
||||
const s = get().scripts.find((x) => x.id === id);
|
||||
if (!s) return "";
|
||||
const text = await client.fetchBlobText(get().accountId!, s.blobId, "application/sieve");
|
||||
set((st) => ({ contents: { ...st.contents, [id]: text } }));
|
||||
return text;
|
||||
},
|
||||
|
||||
rules() {
|
||||
const { scripts, contents } = get();
|
||||
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
|
||||
const content = script ? (contents[script.id] ?? "") : "";
|
||||
return { script, rules: script ? sieveToRules(content) : [], content };
|
||||
},
|
||||
|
||||
async saveRules(rules) {
|
||||
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
|
||||
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
|
||||
},
|
||||
|
||||
async saveScript(id, name, content, activate) {
|
||||
const accountId = get().accountId!;
|
||||
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
|
||||
const args: Record<string, unknown> = { accountId };
|
||||
if (id) args.update = { [id]: { name, blobId: up.blobId } };
|
||||
else args.create = { s: { name, blobId: up.blobId } };
|
||||
if (activate) args.onSuccessActivateScript = id ?? "#s";
|
||||
const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args);
|
||||
const err = id ? res.notUpdated?.[id] : res.notCreated?.s;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const newId = id ?? res.created!.s!.id;
|
||||
set((s) => ({ contents: { ...s.contents, [newId]: content } }));
|
||||
await get().load();
|
||||
return newId;
|
||||
},
|
||||
|
||||
async activate(id) {
|
||||
const accountId = get().accountId!;
|
||||
const args: Record<string, unknown> = { accountId };
|
||||
if (id) args.onSuccessActivateScript = id;
|
||||
else args.onSuccessDeactivateScript = true;
|
||||
// A no-op set with activation hooks.
|
||||
await client.call<SetResponse>("SieveScript/set", args);
|
||||
await get().load();
|
||||
},
|
||||
|
||||
async destroy(id) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] });
|
||||
const err = res.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
await get().load();
|
||||
},
|
||||
|
||||
async validate(content) {
|
||||
const accountId = get().accountId!;
|
||||
try {
|
||||
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
|
||||
const res = await client.call<{ error: { type: string; description?: string } | null }>("SieveScript/validate", { accountId, blobId: up.blobId });
|
||||
return res.error ? (res.error.description ?? res.error.type) : null;
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("SieveScript")) void get().load();
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,842 @@
|
||||
/* ==========================================================================
|
||||
ihasmail design system
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--fs: 14px;
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-2: 0 4px 16px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--row-h: 44px;
|
||||
--topbar-h: 56px;
|
||||
--sidebar-w: 256px;
|
||||
--sidebar-w-collapsed: 68px;
|
||||
--list-w: 42%;
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
/* Light palette */
|
||||
--bg: #f6f8fa;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-sunken: #eef1f4;
|
||||
--bg-hover: rgba(15, 23, 42, 0.05);
|
||||
--bg-active: rgba(15, 23, 42, 0.09);
|
||||
--fg: #111827;
|
||||
--fg-muted: #5b6472;
|
||||
--fg-faint: #8b94a3;
|
||||
--border: #e3e7ec;
|
||||
--border-strong: #cbd2da;
|
||||
--accent: #0f766e;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-soft: #d9f1ee;
|
||||
--accent-soft-fg: #0b5750;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fee2e2;
|
||||
--warn: #b45309;
|
||||
--warn-soft: #fef3c7;
|
||||
--success: #15803d;
|
||||
--success-soft: #dcfce7;
|
||||
--link: #0e7490;
|
||||
--unread-bg: #ffffff;
|
||||
--read-bg: #f3f5f7;
|
||||
--selected-bg: #d9f1ee;
|
||||
--focus-ring: 0 0 0 3px rgba(15, 118, 110, 0.35);
|
||||
--star: #f59e0b;
|
||||
--q1: #2563eb;
|
||||
--q2: #16a34a;
|
||||
--q3: #9333ea;
|
||||
--scrollbar: rgba(0, 0, 0, 0.25);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0b1220;
|
||||
--bg-elev: #111a2b;
|
||||
--bg-sunken: #0d1524;
|
||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-active: rgba(255, 255, 255, 0.1);
|
||||
--fg: #e5e9f0;
|
||||
--fg-muted: #9aa5b8;
|
||||
--fg-faint: #6b7689;
|
||||
--border: #1f2a3d;
|
||||
--border-strong: #31405a;
|
||||
--accent: #2dd4bf;
|
||||
--accent-fg: #052e2b;
|
||||
--accent-soft: rgba(45, 212, 191, 0.16);
|
||||
--accent-soft-fg: #99f6e4;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--warn: #fbbf24;
|
||||
--warn-soft: rgba(251, 191, 36, 0.15);
|
||||
--success: #4ade80;
|
||||
--success-soft: rgba(74, 222, 128, 0.15);
|
||||
--link: #67e8f9;
|
||||
--unread-bg: #121c2e;
|
||||
--read-bg: #0e1627;
|
||||
--selected-bg: rgba(45, 212, 191, 0.18);
|
||||
--focus-ring: 0 0 0 3px rgba(45, 212, 191, 0.4);
|
||||
--star: #fbbf24;
|
||||
--q1: #60a5fa;
|
||||
--q2: #4ade80;
|
||||
--q3: #c084fc;
|
||||
--scrollbar: rgba(255, 255, 255, 0.25);
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Accent variants */
|
||||
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
|
||||
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
|
||||
:root[data-accent="rose"] { --accent: #e11d48; --accent-soft: #ffe4e6; --accent-soft-fg: #881337; --selected-bg: #ffe4e6; --focus-ring: 0 0 0 3px rgba(225,29,72,.35); --link:#be123c; }
|
||||
:root[data-accent="orange"] { --accent: #ea580c; --accent-soft: #ffedd5; --accent-soft-fg: #7c2d12; --selected-bg: #ffedd5; --focus-ring: 0 0 0 3px rgba(234,88,12,.35); --link:#c2410c; }
|
||||
:root[data-accent="green"] { --accent: #16a34a; --accent-soft: #dcfce7; --accent-soft-fg: #14532d; --selected-bg: #dcfce7; --focus-ring: 0 0 0 3px rgba(22,163,74,.35); --link:#15803d; }
|
||||
:root[data-theme="dark"][data-accent="blue"] { --accent: #60a5fa; --accent-fg:#0b1a33; --accent-soft: rgba(96,165,250,.18); --accent-soft-fg:#bfdbfe; --selected-bg: rgba(96,165,250,.2); --link:#93c5fd; }
|
||||
:root[data-theme="dark"][data-accent="purple"] { --accent: #a78bfa; --accent-fg:#1e1040; --accent-soft: rgba(167,139,250,.18); --accent-soft-fg:#ddd6fe; --selected-bg: rgba(167,139,250,.2); --link:#c4b5fd; }
|
||||
:root[data-theme="dark"][data-accent="rose"] { --accent: #fb7185; --accent-fg:#3b0716; --accent-soft: rgba(251,113,133,.18); --accent-soft-fg:#fecdd3; --selected-bg: rgba(251,113,133,.2); --link:#fda4af; }
|
||||
:root[data-theme="dark"][data-accent="orange"] { --accent: #fb923c; --accent-fg:#3b1605; --accent-soft: rgba(251,146,60,.18); --accent-soft-fg:#fed7aa; --selected-bg: rgba(251,146,60,.2); --link:#fdba74; }
|
||||
:root[data-theme="dark"][data-accent="green"] { --accent: #4ade80; --accent-fg:#052e16; --accent-soft: rgba(74,222,128,.18); --accent-soft-fg:#bbf7d0; --selected-bg: rgba(74,222,128,.2); --link:#86efac; }
|
||||
|
||||
:root[data-density="comfortable"] { --row-h: 52px; }
|
||||
:root[data-density="cozy"] { --row-h: 44px; }
|
||||
:root[data-density="compact"] { --row-h: 36px; }
|
||||
:root[data-fontsize="small"] { --fs: 13px; }
|
||||
:root[data-fontsize="large"] { --fs: 15.5px; }
|
||||
|
||||
/* Reset ------------------------------------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs);
|
||||
line-height: 1.45;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; background: none; border: 0; padding: 0; }
|
||||
a { color: var(--link); }
|
||||
img { max-width: 100%; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 8px; }
|
||||
:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.muted { color: var(--fg-muted); }
|
||||
.faint { color: var(--fg-faint); }
|
||||
.small { font-size: 0.875em; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.col { display: flex; flex-direction: column; }
|
||||
.grow { flex: 1 1 auto; min-width: 0; }
|
||||
.spacer { flex: 1; }
|
||||
.hidden { display: none !important; }
|
||||
.wrap { flex-wrap: wrap; }
|
||||
.gap-2 { gap: 2px; } .gap-4 { gap: 4px; } .gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; }
|
||||
.mt-8 { margin-top: 8px; } .mt-16 { margin-top: 16px; } .mb-8 { margin-bottom: 8px; } .mb-16 { margin-bottom: 16px; }
|
||||
.p-16 { padding: 16px; }
|
||||
.center { display:flex; align-items:center; justify-content:center; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* Buttons ---------------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 36px; padding: 0 14px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-elev); color: var(--fg);
|
||||
font-weight: 500; white-space: nowrap; user-select: none; transition: background .12s var(--ease), border-color .12s, box-shadow .12s, transform .05s;
|
||||
}
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
||||
.btn-primary { background: var(--accent); border-color: transparent; color: var(--accent-fg); }
|
||||
.btn-primary:hover { filter: brightness(1.06); background: var(--accent); }
|
||||
.btn-danger { background: var(--danger); border-color: transparent; color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.06); background: var(--danger); }
|
||||
.btn-ghost { border-color: transparent; background: transparent; }
|
||||
.btn-ghost:hover { background: var(--bg-hover); }
|
||||
.btn-soft { background: var(--accent-soft); border-color: transparent; color: var(--accent-soft-fg); }
|
||||
.btn-sm { height: 30px; padding: 0 10px; font-size: .9em; }
|
||||
.btn-lg { height: 44px; padding: 0 20px; font-size: 1.05em; border-radius: var(--radius); }
|
||||
.btn-pill { border-radius: 999px; }
|
||||
.btn-block { width: 100%; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px; border-radius: 50%; color: var(--fg-muted); flex: 0 0 auto;
|
||||
transition: background .12s var(--ease), color .12s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.icon-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.icon-btn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.icon-btn.sm { width: 30px; height: 30px; }
|
||||
.icon-btn.xs { width: 26px; height: 26px; }
|
||||
.icon-btn.danger:hover { color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
/* Inputs ----------------------------------------------------------------- */
|
||||
.input, .select, .textarea {
|
||||
width: 100%; height: 38px; padding: 0 12px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-elev); color: var(--fg);
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.textarea { height: auto; min-height: 90px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
|
||||
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); box-shadow: var(--focus-ring); outline: none; }
|
||||
.input::placeholder, .textarea::placeholder { color: var(--fg-faint); }
|
||||
.input.sm { height: 32px; padding: 0 10px; }
|
||||
.select { appearance: none; padding-right: 30px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238b94a3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field > label, .label { font-size: .85em; font-weight: 600; color: var(--fg-muted); letter-spacing: .01em; }
|
||||
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.hint { font-size: .85em; color: var(--fg-faint); }
|
||||
.check { display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 6px 0; }
|
||||
.check input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
|
||||
.switch { position: relative; width: 40px; height: 22px; border-radius: 999px; background: var(--border-strong); transition: background .15s; flex: 0 0 auto; }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: var(--shadow-1); transition: transform .15s var(--ease); }
|
||||
.switch[aria-checked="true"] { background: var(--accent); }
|
||||
.switch[aria-checked="true"]::after { transform: translateX(18px); }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.switch-row:last-child { border-bottom: 0; }
|
||||
.switch-row .switch-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.switch-row .switch-text .hint { margin: 0; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 999px; background: var(--bg-sunken); border: 1px solid var(--border); font-size: .85em; max-width: 100%; }
|
||||
.chip .chip-x { display: inline-flex; border-radius: 50%; padding: 2px; color: var(--fg-muted); }
|
||||
.chip .chip-x:hover { background: var(--bg-active); color: var(--fg); }
|
||||
.chip.invalid { border-color: var(--danger); color: var(--danger); }
|
||||
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; font-size: 11px; font-weight: 700; background: var(--accent); color: var(--accent-fg); }
|
||||
.badge.muted { background: var(--bg-active); color: var(--fg-muted); }
|
||||
.tag { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border-radius: 6px; font-size: 11.5px; font-weight: 600; color: #fff; }
|
||||
.kbd { display: inline-block; min-width: 20px; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--border-strong); border-bottom-width: 2px; background: var(--bg-sunken); font-family: var(--font-mono); font-size: 11.5px; text-align: center; }
|
||||
|
||||
/* Avatar ----------------------------------------------------------------- */
|
||||
.avatar { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; color: #fff; font-weight: 600; font-size: 13px; flex: 0 0 auto; overflow: hidden; user-select: none; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar.sm { width: 28px; height: 28px; font-size: 11px; }
|
||||
.avatar.lg { width: 56px; height: 56px; font-size: 20px; }
|
||||
.avatar.xl { width: 88px; height: 88px; font-size: 30px; }
|
||||
|
||||
/* Menus / popovers ------------------------------------------------------- */
|
||||
.popover { position: fixed; z-index: 1000; min-width: 180px; max-width: min(420px, calc(100vw - 16px)); max-height: min(70vh, 520px); overflow: auto; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); padding: 6px; animation: pop .12s var(--ease); }
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: var(--radius-sm); text-align: left; color: var(--fg); white-space: nowrap; }
|
||||
.menu-item:hover, .menu-item.active { background: var(--bg-hover); }
|
||||
.menu-item:disabled { opacity: .5; cursor: default; }
|
||||
.menu-item.danger { color: var(--danger); }
|
||||
.menu-item .menu-kbd { margin-left: auto; color: var(--fg-faint); font-size: .85em; }
|
||||
.menu-item svg { color: var(--fg-muted); flex: 0 0 auto; }
|
||||
.menu-item.danger svg { color: var(--danger); }
|
||||
.menu-sep { height: 1px; background: var(--border); margin: 6px 4px; }
|
||||
.menu-title { padding: 6px 10px 4px; font-size: .78em; font-weight: 700; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.menu-search { padding: 4px 4px 6px; }
|
||||
.tooltip { position: fixed; z-index: 2000; background: #1f2937; color: #fff; padding: 5px 8px; border-radius: 6px; font-size: 12px; pointer-events: none; white-space: nowrap; box-shadow: var(--shadow-2); animation: pop .1s var(--ease); }
|
||||
:root[data-theme="dark"] .tooltip { background: #e5e9f0; color: #0b1220; }
|
||||
|
||||
/* Dialogs ---------------------------------------------------------------- */
|
||||
.dialog-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.dialog { background: var(--bg-elev); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); width: 100%; max-width: 520px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; animation: rise .18s var(--ease); border: 1px solid var(--border); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
.dialog.lg { max-width: 760px; }
|
||||
.dialog.xl { max-width: 980px; }
|
||||
.dialog.sm { max-width: 400px; }
|
||||
.dialog-head { display: flex; align-items: center; gap: 8px; padding: 16px 20px 8px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 1.15em; font-weight: 650; flex: 1; }
|
||||
.dialog-body { padding: 8px 20px 16px; overflow: auto; }
|
||||
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
|
||||
.dialog-foot .left { margin-right: auto; }
|
||||
|
||||
/* Toasts ----------------------------------------------------------------- */
|
||||
.toast-host { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; padding: 0 12px; width: 100%; max-width: 520px; }
|
||||
.toast { pointer-events: auto; display: flex; align-items: center; gap: 12px; background: #1f2937; color: #fff; padding: 10px 10px 10px 16px; border-radius: var(--radius); box-shadow: var(--shadow-2); min-width: 260px; max-width: 100%; position: relative; overflow: hidden; animation: rise .2s var(--ease); }
|
||||
:root[data-theme="dark"] .toast { background: #e5e9f0; color: #0b1220; }
|
||||
.toast-error { background: #991b1b; color: #fff; }
|
||||
:root[data-theme="dark"] .toast-error { background: #fecaca; color: #450a0a; }
|
||||
.toast-success { background: #14532d; color: #fff; }
|
||||
:root[data-theme="dark"] .toast-success { background: #bbf7d0; color: #052e16; }
|
||||
.toast-msg { flex: 1; }
|
||||
.toast-action { color: #5eead4; font-weight: 700; padding: 6px 8px; border-radius: 6px; }
|
||||
.toast-action:hover { background: rgba(255,255,255,.12); }
|
||||
:root[data-theme="dark"] .toast-action { color: #0f766e; }
|
||||
.toast-close { color: inherit; opacity: .7; display: inline-flex; padding: 4px; border-radius: 50%; }
|
||||
.toast-close:hover { opacity: 1; background: rgba(255,255,255,.12); }
|
||||
.toast-progress { position: absolute; left: 0; bottom: 0; height: 3px; background: #5eead4; width: 100%; transform-origin: left; animation: shrink linear forwards; }
|
||||
@keyframes shrink { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
|
||||
/* Spinner / skeleton ----------------------------------------------------- */
|
||||
.spinner { width: 20px; height: 20px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex: 0 0 auto; }
|
||||
.spinner.lg { width: 32px; height: 32px; border-width: 3px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.skeleton { background: linear-gradient(90deg, var(--bg-sunken) 25%, var(--bg-hover) 37%, var(--bg-sunken) 63%); background-size: 400% 100%; animation: shimmer 1.4s ease infinite; border-radius: 6px; }
|
||||
@keyframes shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 48px 24px; color: var(--fg-muted); text-align: center; }
|
||||
.empty svg { color: var(--fg-faint); }
|
||||
.empty h3 { margin: 8px 0 0; font-weight: 600; color: var(--fg); }
|
||||
.empty p { margin: 0; max-width: 360px; }
|
||||
.error-box { padding: 12px 14px; border-radius: var(--radius-sm); background: var(--danger-soft); color: var(--danger); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
|
||||
.info-box { padding: 12px 14px; border-radius: var(--radius-sm); background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.warn-box { padding: 12px 14px; border-radius: var(--radius-sm); background: var(--warn-soft); color: var(--warn); }
|
||||
|
||||
/* ==========================================================================
|
||||
App shell
|
||||
========================================================================== */
|
||||
.app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; overflow: hidden; }
|
||||
.topbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg); position: relative; z-index: 50; }
|
||||
.topbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2em; letter-spacing: -.01em; color: var(--fg); text-decoration: none; padding-right: 8px; min-width: 0; }
|
||||
.topbar .brand img { width: 34px; height: 34px; object-fit: contain; }
|
||||
.topbar .brand .brand-name { color: #14b8a6; }
|
||||
.topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; }
|
||||
.searchbar { flex: 1 1 auto; max-width: 720px; margin: 0 auto; position: relative; }
|
||||
.searchbar .search-input { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 14px; border-radius: 999px; background: var(--bg-sunken); border: 1px solid transparent; transition: background .12s, box-shadow .12s, border-color .12s; }
|
||||
.searchbar .search-input:focus-within { background: var(--bg-elev); box-shadow: var(--shadow-1); border-color: var(--border); }
|
||||
.searchbar input { flex: 1; border: 0; background: transparent; outline: none; min-width: 0; height: 100%; }
|
||||
.searchbar input::placeholder { color: var(--fg-faint); }
|
||||
.search-panel { position: absolute; top: calc(100% + 6px); left: 0; right: 0; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); padding: 16px; z-index: 60; animation: pop .12s var(--ease); }
|
||||
.search-panel .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
|
||||
.search-suggest { position: absolute; top: calc(100% + 6px); left: 0; right: 0; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); padding: 6px; z-index: 60; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.push-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg-faint); }
|
||||
.push-dot.on { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
|
||||
|
||||
.app-body { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 0; transition: grid-template-columns .2s var(--ease); }
|
||||
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; }
|
||||
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; }
|
||||
.sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; }
|
||||
.compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; }
|
||||
.compose-btn:hover { box-shadow: var(--shadow-2); background: var(--bg-elev); }
|
||||
.compose-btn svg { color: var(--accent); }
|
||||
.collapsed .compose-btn { width: 52px; padding: 0; justify-content: center; margin-left: auto; margin-right: auto; }
|
||||
.collapsed .compose-btn span { display: none; }
|
||||
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
|
||||
.nav-section .icon-btn { width: 24px; height: 24px; }
|
||||
.collapsed .nav-section { display: none; }
|
||||
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
|
||||
.nav-item:hover { background: var(--bg-hover); }
|
||||
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 650; }
|
||||
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
|
||||
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
|
||||
.nav-item svg { flex: 0 0 auto; color: var(--fg-muted); }
|
||||
.nav-item.active svg { color: inherit; }
|
||||
.nav-item .nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-item .nav-count { font-size: .8em; font-weight: 700; color: var(--fg-muted); }
|
||||
.nav-item.active .nav-count { color: inherit; }
|
||||
.nav-item .nav-more { opacity: 0; width: 24px; height: 24px; margin-right: -6px; }
|
||||
.nav-item:hover .nav-more, .nav-item:focus-within .nav-more { opacity: 1; }
|
||||
.nav-item .nav-twisty { width: 18px; height: 18px; margin-left: -8px; margin-right: -6px; display: inline-flex; align-items: center; justify-content: center; color: var(--fg-faint); border-radius: 4px; }
|
||||
.nav-item .nav-twisty:hover { background: var(--bg-active); }
|
||||
.nav-item.depth-1 { padding-left: 28px; } .nav-item.depth-2 { padding-left: 44px; } .nav-item.depth-3 { padding-left: 60px; } .nav-item.depth-4 { padding-left: 76px; }
|
||||
.collapsed .nav-item { justify-content: center; padding: 0; margin: 0 auto; width: 44px; border-radius: 999px; }
|
||||
.collapsed .nav-item .nav-label, .collapsed .nav-item .nav-count, .collapsed .nav-item .nav-more, .collapsed .nav-item .nav-twisty { display: none; }
|
||||
.collapsed .nav-item.depth-1, .collapsed .nav-item.depth-2, .collapsed .nav-item.depth-3 { display: none; }
|
||||
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* Outlook-style module bar at the bottom of the sidebar */
|
||||
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
||||
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.module-link.active { color: var(--accent-soft-fg); background: var(--accent-soft); }
|
||||
.module-link svg { flex: 0 0 auto; }
|
||||
.module-link .module-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.collapsed .module-bar { flex-direction: column; gap: 4px; }
|
||||
.collapsed .module-link .module-label { display: none; }
|
||||
.collapsed .module-link { width: 44px; height: 44px; margin: 0 auto; padding: 0; border-radius: 999px; }
|
||||
.quota { padding: 10px 12px; font-size: .8em; color: var(--fg-muted); }
|
||||
.quota-bar { height: 4px; background: var(--bg-active); border-radius: 4px; overflow: hidden; margin-top: 6px; }
|
||||
.quota-bar > span { display: block; height: 100%; background: var(--accent); }
|
||||
.quota-bar > span.warn { background: var(--warn); }
|
||||
.quota-bar > span.danger { background: var(--danger); }
|
||||
.collapsed .quota { display: none; }
|
||||
.main { min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) 0 0 0; border: 1px solid var(--border); border-right: 0; border-bottom: 0; overflow: hidden; }
|
||||
|
||||
/* Mobile drawer */
|
||||
.drawer-backdrop { display: none; }
|
||||
.mobile-tabbar { display: none; }
|
||||
.fab { display: none; }
|
||||
|
||||
/* ==========================================================================
|
||||
Mail
|
||||
========================================================================== */
|
||||
.mail-layout { display: grid; grid-template-columns: minmax(340px, var(--list-w)) 1fr; height: 100%; min-height: 0; flex: 1; }
|
||||
.mail-layout.pane-off { grid-template-columns: 1fr; }
|
||||
.mail-layout.pane-off.reading { grid-template-columns: 1fr; }
|
||||
.mail-layout .mail-list-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; border-right: 1px solid var(--border); }
|
||||
.mail-layout.pane-bottom .mail-list-pane { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.mail-layout.pane-off .mail-list-pane { border-right: 0; }
|
||||
.mail-layout .mail-reading-pane { min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--bg-elev); }
|
||||
.list-toolbar { display: flex; align-items: center; gap: 2px; height: 48px; padding: 0 8px 0 12px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.list-toolbar .tb-sep { width: 1px; height: 20px; background: var(--border); margin: 0 6px; }
|
||||
.list-toolbar .tb-title { font-weight: 650; font-size: 1.02em; margin-right: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-toolbar .tb-count { color: var(--fg-muted); font-size: .85em; white-space: nowrap; }
|
||||
.select-all { width: 18px; height: 18px; accent-color: var(--accent); margin: 0 6px 0 0; cursor: pointer; }
|
||||
.mail-list { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; position: relative; outline: none; }
|
||||
.mail-list-inner { position: relative; width: 100%; }
|
||||
.msg-row { position: absolute; left: 0; right: 0; display: flex; align-items: center; gap: 8px; height: var(--row-h); padding: 0 12px 0 8px; border-bottom: 1px solid var(--border); background: var(--read-bg); cursor: pointer; user-select: none; -webkit-user-select: none; transition: box-shadow .1s; }
|
||||
.msg-row.unread { background: var(--unread-bg); }
|
||||
.msg-row.unread .msg-from, .msg-row.unread .msg-subject { font-weight: 700; color: var(--fg); }
|
||||
.msg-row:hover { box-shadow: inset 0 0 0 1px var(--border-strong), var(--shadow-1); z-index: 2; }
|
||||
.msg-row.selected { background: var(--selected-bg); }
|
||||
.msg-row.focused { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.msg-row.open { background: var(--selected-bg); }
|
||||
.msg-row.dragging { opacity: .5; }
|
||||
.msg-row .msg-check { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; flex: 0 0 auto; opacity: 0; transition: opacity .1s; cursor: pointer; }
|
||||
.msg-row:hover .msg-check, .msg-row.selected .msg-check, .mail-list.has-selection .msg-check { opacity: 1; }
|
||||
.msg-row .msg-star { color: var(--fg-faint); flex: 0 0 auto; display: inline-flex; padding: 4px; border-radius: 50%; }
|
||||
.msg-row .msg-star:hover { color: var(--star); background: var(--bg-hover); }
|
||||
.msg-row .msg-star.on { color: var(--star); }
|
||||
.msg-row .msg-from { width: 180px; flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); display: flex; align-items: center; gap: 6px; }
|
||||
.msg-row .msg-from .thread-count { color: var(--fg-muted); font-weight: 400; font-size: .9em; }
|
||||
.msg-row .msg-main { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 6px; overflow: hidden; }
|
||||
.msg-row .msg-subject { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; min-width: 40%; max-width: 100%; color: var(--fg); }
|
||||
.msg-row .msg-preview { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--fg-muted); flex: 1 1 0; min-width: 0; }
|
||||
.msg-row .msg-preview::before { content: " – "; }
|
||||
.msg-row .msg-meta { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; color: var(--fg-muted); font-size: .85em; white-space: nowrap; }
|
||||
.msg-row.unread .msg-meta .msg-date { color: var(--fg); font-weight: 700; }
|
||||
.msg-row .msg-actions { display: none; align-items: center; gap: 0; margin-left: 4px; }
|
||||
.msg-row:hover .msg-actions { display: flex; }
|
||||
.msg-row:hover .msg-meta .msg-date { display: none; }
|
||||
.msg-row .msg-labels { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||
.msg-row .msg-labels .tag { height: 18px; font-size: 10.5px; padding: 0 6px; }
|
||||
.msg-row .avatar { width: 32px; height: 32px; font-size: 12px; }
|
||||
.mail-list.compact .msg-row .avatar { display: none; }
|
||||
.msg-row .msg-attach { color: var(--fg-faint); }
|
||||
.msg-row .msg-answered { color: var(--fg-faint); }
|
||||
.msg-row .msg-important { color: var(--warn); }
|
||||
.list-footer { padding: 12px; text-align: center; color: var(--fg-muted); font-size: .9em; }
|
||||
.list-hint { padding: 8px 12px; font-size: .85em; color: var(--fg-muted); background: var(--bg-sunken); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
||||
.list-hint button { color: var(--link); font-weight: 600; }
|
||||
.drag-ghost { position: fixed; top: -1000px; left: -1000px; padding: 8px 12px; background: var(--accent); color: var(--accent-fg); border-radius: 999px; font-weight: 600; box-shadow: var(--shadow-2); pointer-events: none; z-index: 5000; }
|
||||
|
||||
/* Splitter between list and reading pane */
|
||||
.splitter { position: relative; background: var(--border); flex: 0 0 auto; z-index: 3; touch-action: none; }
|
||||
.splitter.vertical { width: 6px; cursor: col-resize; }
|
||||
.splitter.horizontal { height: 6px; cursor: row-resize; }
|
||||
.splitter:hover, .splitter:focus-visible { background: var(--accent); box-shadow: none; }
|
||||
.splitter .splitter-grip { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 4px; background: var(--fg-faint); opacity: 0; transition: opacity .15s; }
|
||||
.splitter.vertical .splitter-grip { width: 3px; height: 36px; }
|
||||
.splitter.horizontal .splitter-grip { height: 3px; width: 36px; }
|
||||
.splitter:hover .splitter-grip { opacity: .8; }
|
||||
.mail-layout.pane-right { grid-template-columns: var(--list-size, 520px) auto 1fr; }
|
||||
.mail-layout.pane-bottom { grid-template-columns: 1fr; grid-template-rows: var(--list-size, 340px) auto 1fr; }
|
||||
|
||||
/* Two-line rows (narrow lists / mobile) */
|
||||
.mail-list.two-line .msg-row { height: calc(var(--row-h) * 1.5); align-items: flex-start; padding-top: 8px; }
|
||||
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
||||
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
||||
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
||||
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
||||
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
||||
.mail-list.two-line .msg-row .msg-meta { margin-left: auto; }
|
||||
.mail-list.two-line .msg-row:hover .msg-meta .msg-date { display: inline; }
|
||||
.mail-list.two-line .msg-row:hover .msg-actions { display: none; }
|
||||
|
||||
/* Reading pane / thread view */
|
||||
.thread-view { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
.thread-toolbar { display: flex; align-items: center; gap: 2px; height: 48px; padding: 0 8px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.thread-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 0 80px; }
|
||||
.thread-subject { padding: 20px 24px 8px; display: flex; align-items: flex-start; gap: 12px; }
|
||||
.thread-subject h1 { margin: 0; font-size: 1.35em; font-weight: 600; line-height: 1.3; flex: 1; overflow-wrap: anywhere; }
|
||||
.thread-subject .labels { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.message { margin: 0 16px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-elev); transition: box-shadow .12s; }
|
||||
.message.collapsed { cursor: pointer; }
|
||||
.message.collapsed:hover { box-shadow: var(--shadow-1); }
|
||||
.message.unread-msg { border-left: 3px solid var(--accent); }
|
||||
.message-head { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; }
|
||||
.message-head .who { flex: 1; min-width: 0; }
|
||||
.message-head .who .from { font-weight: 650; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.message-head .who .from .email { color: var(--fg-muted); font-weight: 400; font-size: .9em; }
|
||||
.message-head .who .to { color: var(--fg-muted); font-size: .88em; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.message-head .who .to button { color: var(--fg-muted); display: inline-flex; align-items: center; }
|
||||
.message-head .who .snippet { color: var(--fg-muted); font-size: .92em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.message-head .meta { display: flex; align-items: center; gap: 2px; color: var(--fg-muted); font-size: .85em; white-space: nowrap; flex: 0 0 auto; }
|
||||
.message-head .meta .date { margin-right: 6px; }
|
||||
.message-details { margin: 0 16px 8px; padding: 10px 12px; background: var(--bg-sunken); border-radius: var(--radius-sm); font-size: .88em; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; }
|
||||
.message-details dt { color: var(--fg-muted); }
|
||||
.message-details dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.message-body { padding: 4px 16px 16px; }
|
||||
.message-body .body-host { display: block; }
|
||||
.remote-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--warn-soft); color: var(--warn); border-radius: var(--radius-sm); font-size: .9em; }
|
||||
.remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.quote-toggle { display: inline-flex; align-items: center; gap: 4px; margin: 8px 0; padding: 2px 10px; border-radius: 999px; background: var(--bg-sunken); color: var(--fg-muted); font-size: 12px; border: 1px solid var(--border); }
|
||||
.quote-toggle:hover { background: var(--bg-active); }
|
||||
.attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; }
|
||||
.attachment { display: flex; align-items: center; gap: 10px; width: 240px; max-width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-elev); text-decoration: none; color: inherit; position: relative; transition: box-shadow .12s; }
|
||||
.attachment:hover { box-shadow: var(--shadow-1); }
|
||||
.attachment .att-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-soft-fg); flex: 0 0 auto; overflow: hidden; }
|
||||
.attachment .att-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.attachment .att-text { flex: 1; min-width: 0; }
|
||||
.attachment .att-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .92em; }
|
||||
.attachment .att-size { color: var(--fg-muted); font-size: .8em; }
|
||||
.attachment .att-actions { display: none; gap: 0; }
|
||||
.attachment:hover .att-actions { display: flex; }
|
||||
.attachment:hover .att-size { display: none; }
|
||||
.attachment .att-progress { position: absolute; left: 0; bottom: 0; height: 3px; background: var(--accent); border-radius: 0 0 0 6px; }
|
||||
.attachment.error { border-color: var(--danger); }
|
||||
.thread-actions { display: flex; gap: 8px; padding: 8px 16px 16px; flex-wrap: wrap; }
|
||||
.invite-card { margin: 0 16px 12px; padding: 14px 16px; border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||
.invite-card h4 { margin: 0 0 4px; }
|
||||
.invite-card .rsvp { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.vcard-card { margin: 0 16px 12px; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-sunken); display: flex; align-items: center; gap: 12px; }
|
||||
.unsubscribe-row { margin: 0 16px 8px; font-size: .88em; color: var(--fg-muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.reply-box { margin: 8px 16px 24px; }
|
||||
.reply-box .reply-prompt { display: flex; gap: 8px; align-items: center; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg-muted); }
|
||||
.reply-box .reply-prompt button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border-strong); color: var(--fg); font-weight: 500; }
|
||||
.reply-box .reply-prompt button:hover { background: var(--bg-hover); }
|
||||
.no-thread { height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: var(--fg-muted); }
|
||||
.no-thread img { width: 140px; opacity: .6; filter: grayscale(.3); }
|
||||
.thread-nav { display: flex; align-items: center; gap: 2px; margin-left: auto; color: var(--fg-muted); font-size: .85em; }
|
||||
.print-only { display: none; }
|
||||
|
||||
/* Labels (keywords) */
|
||||
.label-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
|
||||
/* ==========================================================================
|
||||
Composer
|
||||
========================================================================== */
|
||||
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 800; pointer-events: none; }
|
||||
.composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); }
|
||||
.composer.minimized { height: 44px; width: 280px; }
|
||||
.composer.maximized { position: fixed; inset: 24px; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); }
|
||||
.composer-head { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px 0 14px; background: var(--bg-sunken); border-bottom: 1px solid var(--border); flex: 0 0 auto; cursor: default; }
|
||||
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
|
||||
.composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.composer-fields { flex: 0 0 auto; padding: 0 12px; }
|
||||
.composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; }
|
||||
.composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; }
|
||||
.composer-field .field-extra { display: flex; gap: 4px; color: var(--fg-muted); font-size: .85em; }
|
||||
.composer-field .field-extra button { color: var(--fg-muted); padding: 2px 6px; border-radius: 4px; }
|
||||
.composer-field .field-extra button:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.composer-field input.plain { flex: 1; border: 0; background: transparent; outline: none; min-width: 80px; height: 30px; }
|
||||
.composer-field .from-select { flex: 1; border: 0; background: transparent; padding: 0; height: 30px; cursor: pointer; }
|
||||
.recipients { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; position: relative; }
|
||||
.recipients .chip { height: 24px; }
|
||||
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
|
||||
.composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
||||
.editor-area { flex: 1; min-height: 120px; overflow-y: auto; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); }
|
||||
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
||||
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
||||
.editor-area img { max-width: 100%; height: auto; }
|
||||
.editor-area a { color: var(--link); }
|
||||
.editor-area .ihm-signature { color: var(--fg-muted); }
|
||||
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
||||
.editor-textarea { flex: 1; border: 0; resize: none; outline: none; padding: 12px 16px; font-family: var(--font-mono); font-size: 13.5px; line-height: 1.5; background: transparent; }
|
||||
.editor-toolbar { display: flex; align-items: center; gap: 1px; flex-wrap: wrap; padding: 4px 8px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.editor-toolbar .icon-btn { width: 30px; height: 30px; border-radius: 6px; }
|
||||
.editor-toolbar .tb-sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
|
||||
.editor-toolbar select { height: 28px; border: 0; background: transparent; color: var(--fg-muted); font-size: .85em; max-width: 110px; }
|
||||
.composer-attachments { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px 0; max-height: 140px; overflow-y: auto; }
|
||||
.composer-attachments .attachment { width: 200px; padding: 6px 8px; }
|
||||
.composer-foot { display: flex; align-items: center; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.composer-foot .send-group { display: flex; }
|
||||
.composer-foot .send-group .btn:first-child { border-radius: 999px 0 0 999px; padding-left: 20px; }
|
||||
.composer-foot .send-group .btn:last-child { border-radius: 0 999px 999px 0; padding: 0 8px; border-left: 1px solid rgba(255,255,255,.3); }
|
||||
.composer-foot .more-actions { display: flex; align-items: center; gap: 2px; margin-left: 8px; }
|
||||
.composer.dropping .composer-body { outline: 3px dashed var(--accent); outline-offset: -8px; }
|
||||
.suggest-list { position: absolute; top: 100%; left: 0; min-width: 300px; max-width: 100%; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); z-index: 20; padding: 4px; max-height: 260px; overflow: auto; }
|
||||
.suggest-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.suggest-item.active, .suggest-item:hover { background: var(--bg-hover); }
|
||||
.suggest-item .s-name { font-weight: 550; }
|
||||
.suggest-item .s-email { color: var(--fg-muted); font-size: .85em; }
|
||||
.suggest-item .s-src { margin-left: auto; font-size: .72em; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(8, 32px); gap: 2px; padding: 4px; }
|
||||
.emoji-grid button { font-size: 20px; height: 32px; border-radius: 6px; }
|
||||
.emoji-grid button:hover { background: var(--bg-hover); }
|
||||
.link-popup { display: flex; gap: 6px; padding: 8px; }
|
||||
.color-grid { display: grid; grid-template-columns: repeat(8, 22px); gap: 4px; padding: 6px; }
|
||||
.color-grid button { width: 22px; height: 22px; border-radius: 4px; border: 1px solid rgba(0,0,0,.1); }
|
||||
|
||||
/* ==========================================================================
|
||||
Settings
|
||||
========================================================================== */
|
||||
.settings-layout { display: grid; grid-template-columns: 240px 1fr; height: 100%; min-height: 0; flex: 1; }
|
||||
.settings-nav { border-right: 1px solid var(--border); padding: 12px 8px; overflow-y: auto; }
|
||||
.settings-nav .nav-item { margin-right: 0; border-radius: var(--radius-sm); }
|
||||
.settings-content { overflow-y: auto; padding: 24px 32px 64px; max-width: 860px; }
|
||||
.settings-content h1 { margin: 0 0 4px; font-size: 1.5em; font-weight: 650; }
|
||||
.settings-content h2 { margin: 28px 0 12px; font-size: 1.05em; font-weight: 650; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||
.settings-content .lead { color: var(--fg-muted); margin: 0 0 20px; }
|
||||
.card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-elev); padding: 14px 16px; margin-bottom: 12px; }
|
||||
.card.clickable { cursor: pointer; transition: box-shadow .12s; }
|
||||
.card.clickable:hover { box-shadow: var(--shadow-1); }
|
||||
.card-head { display: flex; align-items: center; gap: 10px; }
|
||||
.card-head h3 { margin: 0; font-size: 1em; font-weight: 650; flex: 1; }
|
||||
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
|
||||
.theme-card { border: 2px solid var(--border); border-radius: var(--radius); padding: 8px; cursor: pointer; text-align: center; font-size: .9em; }
|
||||
.theme-card.active { border-color: var(--accent); }
|
||||
.theme-card .preview { height: 56px; border-radius: 6px; margin-bottom: 6px; border: 1px solid var(--border); }
|
||||
.swatches { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.swatch { width: 32px; height: 32px; border-radius: 50%; border: 3px solid transparent; cursor: pointer; }
|
||||
.swatch.active { border-color: var(--fg); }
|
||||
.rule-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px; background: var(--bg-elev); }
|
||||
.rule-card.disabled { opacity: .6; }
|
||||
.rule-row { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.rule-row.actions { grid-template-columns: 1fr 2fr auto; }
|
||||
.code { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; white-space: pre; overflow: auto; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; min-height: 240px; width: 100%; resize: vertical; tab-size: 2; }
|
||||
.shortcut-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
|
||||
.shortcut-grid h3 { margin: 0 0 6px; font-size: .9em; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); }
|
||||
.shortcut-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; gap: 12px; }
|
||||
.shortcut-row .keys { display: flex; gap: 4px; }
|
||||
.sessions-table { width: 100%; border-collapse: collapse; font-size: .92em; }
|
||||
.sessions-table th, .sessions-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.sessions-table th { color: var(--fg-muted); font-weight: 600; font-size: .85em; }
|
||||
|
||||
/* ==========================================================================
|
||||
Contacts
|
||||
========================================================================== */
|
||||
.contacts-layout { display: grid; grid-template-columns: 220px minmax(280px, 360px) 1fr; height: 100%; min-height: 0; flex: 1; }
|
||||
.contacts-books { border-right: 1px solid var(--border); padding: 12px 8px; overflow-y: auto; overflow-x: hidden; }
|
||||
.contacts-list { border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
|
||||
.contacts-list .list-search { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.contacts-scroll { flex: 1; overflow-y: auto; }
|
||||
.contact-row { display: flex; align-items: center; gap: 12px; padding: 8px 14px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.contact-row:hover { background: var(--bg-hover); }
|
||||
.contact-row.active { background: var(--selected-bg); }
|
||||
.contact-row .c-name { font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-row .c-email { color: var(--fg-muted); font-size: .85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-letter { position: sticky; top: 0; background: var(--bg-sunken); padding: 4px 14px; font-size: .78em; font-weight: 700; color: var(--fg-muted); letter-spacing: .05em; z-index: 1; }
|
||||
.contact-detail { overflow-y: auto; padding: 28px 32px 64px; }
|
||||
.contact-hero { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
|
||||
.contact-hero h1 { margin: 0; font-size: 1.6em; font-weight: 650; }
|
||||
.contact-hero .sub { color: var(--fg-muted); }
|
||||
.contact-section { margin-bottom: 20px; }
|
||||
.contact-section h3 { margin: 0 0 8px; font-size: .8em; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-faint); }
|
||||
.contact-kv { display: flex; align-items: flex-start; gap: 12px; padding: 6px 0; }
|
||||
.contact-kv .k { width: 90px; color: var(--fg-muted); font-size: .9em; flex: 0 0 auto; padding-top: 2px; }
|
||||
.contact-kv .v { flex: 1; overflow-wrap: anywhere; }
|
||||
.contact-form .multi { display: flex; flex-direction: column; gap: 8px; }
|
||||
.contact-form .multi-row { display: grid; grid-template-columns: 1fr 130px auto; gap: 8px; align-items: center; }
|
||||
.contact-form .multi-row.address { grid-template-columns: 1fr auto; }
|
||||
.contact-form .addr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Calendar
|
||||
========================================================================== */
|
||||
.cal-layout { display: grid; grid-template-columns: 240px 1fr; height: 100%; min-height: 0; }
|
||||
.cal-side { border-right: 1px solid var(--border); padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.cal-main { display: flex; flex-direction: column; min-height: 0; min-width: 0; flex: 1; height: 100%; }
|
||||
.cal-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.cal-toolbar h2 { margin: 0 8px; font-size: 1.2em; font-weight: 650; min-width: 0; white-space: nowrap; }
|
||||
.view-switch { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.view-switch button { padding: 0 12px; height: 34px; font-weight: 500; color: var(--fg-muted); }
|
||||
.view-switch button.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.view-switch button:not(:last-child) { border-right: 1px solid var(--border-strong); }
|
||||
.mini-cal { font-size: .85em; }
|
||||
.mini-cal .mc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-weight: 650; }
|
||||
.mini-cal .mc-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; }
|
||||
.mini-cal .mc-dow { color: var(--fg-faint); font-size: .85em; padding: 2px 0; }
|
||||
.mini-cal .mc-day { height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.mini-cal .mc-day:hover { background: var(--bg-hover); }
|
||||
.mini-cal .mc-day.other { color: var(--fg-faint); }
|
||||
.mini-cal .mc-day.today { font-weight: 700; color: var(--accent); }
|
||||
.mini-cal .mc-day.selected { background: var(--accent); color: var(--accent-fg); }
|
||||
.mini-cal .mc-day.has-events::after { content: ""; display: block; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); position: relative; top: 9px; left: -1px; }
|
||||
.cal-list-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.cal-list-item:hover { background: var(--bg-hover); }
|
||||
.cal-list-item .cal-color { width: 14px; height: 14px; border-radius: 4px; border: 2px solid transparent; flex: 0 0 auto; }
|
||||
.cal-list-item.hidden-cal .cal-color { background: transparent !important; }
|
||||
.cal-list-item .cal-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cal-list-item .nav-more { opacity: 0; }
|
||||
.cal-list-item:hover .nav-more { opacity: 1; }
|
||||
.month-grid { flex: 1; display: grid; grid-template-rows: auto repeat(6, 1fr); min-height: 0; }
|
||||
.month-grid .dow-row { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--border); }
|
||||
.month-grid .dow-row div { padding: 6px; text-align: center; font-size: .8em; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.month-grid .week-row { display: grid; grid-template-columns: repeat(7, 1fr); min-height: 0; border-bottom: 1px solid var(--border); }
|
||||
.month-cell { border-right: 1px solid var(--border); padding: 4px; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 2px; overflow: hidden; cursor: pointer; position: relative; }
|
||||
.month-cell:last-child { border-right: 0; }
|
||||
.month-cell.other { background: var(--bg-sunken); color: var(--fg-faint); }
|
||||
.month-cell:hover { background: var(--bg-hover); }
|
||||
.month-cell .day-num { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: .9em; font-weight: 500; align-self: flex-start; }
|
||||
.month-cell.today .day-num { background: var(--accent); color: var(--accent-fg); font-weight: 700; }
|
||||
.month-cell .more { font-size: .78em; color: var(--fg-muted); padding-left: 4px; font-weight: 600; }
|
||||
.ev-chip { display: flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 4px; font-size: .8em; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: #fff; font-weight: 500; flex: 0 0 auto; border: 1px solid transparent; }
|
||||
.ev-chip.timed { background: transparent !important; color: var(--fg); border-color: transparent !important; }
|
||||
.ev-chip.timed .ev-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.ev-chip.timed .ev-time { color: var(--fg-muted); }
|
||||
.ev-chip:hover { filter: brightness(.95); }
|
||||
.ev-chip.timed:hover { background: var(--bg-hover) !important; }
|
||||
.ev-chip.declined { text-decoration: line-through; opacity: .6; }
|
||||
.ev-chip.tentative { border-style: dashed; opacity: .85; }
|
||||
.ev-chip.cancelled { text-decoration: line-through; opacity: .5; }
|
||||
.week-view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.week-head { display: grid; grid-template-columns: 56px repeat(var(--cols, 7), 1fr); border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.week-head .wh-day { padding: 8px 4px 4px; text-align: center; border-left: 1px solid var(--border); cursor: pointer; }
|
||||
.week-head .wh-day .dow { font-size: .75em; text-transform: uppercase; color: var(--fg-muted); font-weight: 600; }
|
||||
.week-head .wh-day .dnum { font-size: 1.4em; font-weight: 500; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; }
|
||||
.week-head .wh-day.today .dnum { background: var(--accent); color: var(--accent-fg); }
|
||||
.week-allday { display: grid; grid-template-columns: 56px repeat(var(--cols, 7), 1fr); border-bottom: 1px solid var(--border); min-height: 28px; flex: 0 0 auto; }
|
||||
.week-allday .ad-cell { border-left: 1px solid var(--border); padding: 2px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.week-allday .ad-label { font-size: .7em; color: var(--fg-faint); text-align: right; padding: 4px 6px; }
|
||||
.week-scroll { flex: 1; overflow-y: auto; position: relative; }
|
||||
.week-body { display: grid; grid-template-columns: 56px repeat(var(--cols, 7), 1fr); position: relative; height: calc(24 * var(--hour-h, 48px)); }
|
||||
.week-body .time-col { position: relative; }
|
||||
.week-body .time-col .hour-label { position: absolute; right: 6px; transform: translateY(-50%); font-size: .72em; color: var(--fg-faint); }
|
||||
.week-body .day-col { position: relative; border-left: 1px solid var(--border); cursor: pointer; }
|
||||
.week-body .day-col.today { background: color-mix(in srgb, var(--accent-soft) 35%, transparent); }
|
||||
.week-body .hour-line { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border); pointer-events: none; }
|
||||
.week-body .half-line { position: absolute; left: 0; right: 0; border-top: 1px dashed color-mix(in srgb, var(--border) 60%, transparent); pointer-events: none; }
|
||||
.week-body .now-line { position: absolute; left: 0; right: 0; border-top: 2px solid var(--danger); z-index: 5; pointer-events: none; }
|
||||
.week-body .now-line::before { content: ""; position: absolute; left: -5px; top: -6px; width: 10px; height: 10px; border-radius: 50%; background: var(--danger); }
|
||||
.week-body .work-hours { position: absolute; left: 0; right: 0; background: color-mix(in srgb, var(--bg-elev) 60%, transparent); pointer-events: none; }
|
||||
.ev-block { position: absolute; border-radius: 6px; padding: 3px 6px; font-size: .8em; color: #fff; overflow: hidden; cursor: pointer; box-shadow: var(--shadow-1); border: 1px solid rgba(255,255,255,.4); line-height: 1.3; z-index: 2; transition: box-shadow .1s; }
|
||||
.ev-block:hover { box-shadow: var(--shadow-2); z-index: 3; }
|
||||
.ev-block .ev-title { font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ev-block .ev-time { opacity: .9; font-size: .92em; }
|
||||
.ev-block.declined { opacity: .5; text-decoration: line-through; }
|
||||
.ev-block.tentative { background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 6px, transparent 6px 12px); }
|
||||
.ev-block.draft-new { opacity: .7; border: 2px dashed #fff; }
|
||||
.agenda { overflow-y: auto; padding: 12px 16px 64px; }
|
||||
.agenda-day { display: grid; grid-template-columns: 120px 1fr; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.agenda-day .ad-date { font-weight: 600; }
|
||||
.agenda-day .ad-date.today { color: var(--accent); }
|
||||
.agenda-day .ad-date small { display: block; color: var(--fg-muted); font-weight: 400; }
|
||||
.agenda-ev { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.agenda-ev:hover { background: var(--bg-hover); }
|
||||
.agenda-ev .ev-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.agenda-ev .ev-when { width: 140px; color: var(--fg-muted); font-size: .9em; flex: 0 0 auto; }
|
||||
.event-popover { width: 360px; max-width: calc(100vw - 24px); padding: 14px 16px; }
|
||||
.event-popover h3 { margin: 0 0 6px; font-size: 1.15em; font-weight: 650; padding-left: 16px; position: relative; }
|
||||
.event-popover h3::before { content: ""; position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 3px; background: var(--ev-color, var(--accent)); }
|
||||
.event-popover .ev-line { display: flex; gap: 10px; align-items: flex-start; color: var(--fg-muted); font-size: .92em; margin: 6px 0; }
|
||||
.event-popover .ev-line svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.participant-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: .92em; }
|
||||
.participant-row .p-status { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.p-status.accepted { background: var(--success); } .p-status.declined { background: var(--danger); } .p-status.tentative { background: var(--warn); } .p-status.needs-action { background: var(--fg-faint); }
|
||||
.event-form .time-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
|
||||
.event-form .alerts-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.event-form .alerts-list .row { gap: 6px; }
|
||||
.freebusy { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||
.freebusy .fb-row { display: flex; align-items: center; gap: 8px; font-size: .85em; }
|
||||
.freebusy .fb-bar { flex: 1; height: 14px; background: var(--bg-sunken); border-radius: 4px; position: relative; overflow: hidden; }
|
||||
.freebusy .fb-busy { position: absolute; top: 0; bottom: 0; background: var(--danger); opacity: .65; }
|
||||
.freebusy .fb-window { position: absolute; top: 0; bottom: 0; border: 2px solid var(--accent); border-radius: 3px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Files
|
||||
========================================================================== */
|
||||
.files-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; flex: 1; }
|
||||
.files-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; overflow: hidden; }
|
||||
.breadcrumb button { padding: 4px 8px; border-radius: 6px; color: var(--fg-muted); font-weight: 500; white-space: nowrap; }
|
||||
.breadcrumb button:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.breadcrumb button.current { color: var(--fg); font-weight: 650; }
|
||||
.files-table { width: 100%; border-collapse: collapse; }
|
||||
.files-table th { text-align: left; padding: 8px 12px; font-size: .8em; color: var(--fg-muted); font-weight: 600; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-elev); z-index: 1; }
|
||||
.files-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.files-table tr { cursor: pointer; }
|
||||
.files-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.files-table tr.selected { background: var(--selected-bg); }
|
||||
.files-table .f-name { display: flex; align-items: center; gap: 10px; font-weight: 500; }
|
||||
.files-table .f-name svg { color: var(--fg-muted); }
|
||||
.files-scroll { flex: 1; overflow: auto; }
|
||||
.files-layout.dropping .files-scroll { outline: 3px dashed var(--accent); outline-offset: -10px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Login
|
||||
========================================================================== */
|
||||
.login-page { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 24px; background: radial-gradient(1200px 600px at 10% -10%, var(--accent-soft), transparent 60%), radial-gradient(900px 500px at 110% 110%, var(--accent-soft), transparent 60%), var(--bg); }
|
||||
.login-card { width: 100%; max-width: 400px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-2); padding: 32px 28px 24px; animation: rise .25s var(--ease); }
|
||||
.login-card .logo { display: flex; flex-direction: column; align-items: center; gap: 6px; margin-bottom: 20px; }
|
||||
.login-card .logo img { width: 120px; height: auto; }
|
||||
.login-card h1 { margin: 0; font-size: 1.4em; font-weight: 700; }
|
||||
.login-card .sub { color: var(--fg-muted); font-size: .92em; margin: 0; }
|
||||
.login-card .tagline { color: var(--fg-muted); font-size: 1em; margin: 4px 0 0; text-align: center; }
|
||||
.login-card .foot a { color: var(--fg-muted); text-decoration: underline; }
|
||||
.login-card .foot { margin-top: 20px; font-size: .8em; color: var(--fg-faint); text-align: center; }
|
||||
.login-card .pw-wrap { position: relative; }
|
||||
.login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 1100px) {
|
||||
:root { --list-w: 44%; }
|
||||
.msg-row .msg-from { width: 140px; }
|
||||
.contacts-layout { grid-template-columns: 200px 300px 1fr; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.mail-layout:not(.pane-off):not(.pane-bottom) { grid-template-columns: 1fr !important; }
|
||||
.mail-layout.pane-bottom { grid-template-rows: 1fr !important; }
|
||||
.mail-layout.pane-bottom .mail-reading-pane { display: none; }
|
||||
.mail-layout.pane-bottom.reading .mail-list-pane { display: none; }
|
||||
.mail-layout.pane-bottom.reading .mail-reading-pane { display: flex; }
|
||||
.mail-layout .splitter { display: none; }
|
||||
.mail-layout:not(.pane-bottom) .mail-reading-pane { display: none; }
|
||||
.mail-layout.reading .mail-list-pane { display: none; }
|
||||
.mail-layout.reading .mail-reading-pane { display: flex; }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-nav { display: none; }
|
||||
.settings-layout.section .settings-nav { display: none; }
|
||||
.settings-layout.root .settings-nav { display: block; border-right: 0; }
|
||||
.settings-layout.root .settings-content { display: none; }
|
||||
.settings-content { padding: 16px 16px 80px; }
|
||||
.contacts-layout { grid-template-columns: 1fr; }
|
||||
.contacts-books { display: none; }
|
||||
.contacts-layout.detail .contacts-list { display: none; }
|
||||
.contacts-layout:not(.detail) .contact-detail { display: none; }
|
||||
.cal-layout { grid-template-columns: 1fr; }
|
||||
.cal-side { display: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
:root { --topbar-h: 52px; }
|
||||
.app-body, .app-body.collapsed { grid-template-columns: 1fr; }
|
||||
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; width: min(300px, 85vw); background: var(--bg-elev); z-index: 950; transform: translateX(-105%); transition: transform .22s var(--ease); box-shadow: var(--shadow-3); padding-top: 12px; }
|
||||
.sidebar.open { transform: none; }
|
||||
.collapsed .sidebar .nav-item, .collapsed .sidebar .compose-btn { all: revert; }
|
||||
.collapsed .module-bar { flex-direction: row; }
|
||||
.collapsed .module-link { width: auto; height: auto; padding: 6px 2px; border-radius: var(--radius-sm); }
|
||||
.collapsed .module-link .module-label { display: block; }
|
||||
.drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 940; background: rgba(2,6,23,.45); opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
.main { border-radius: 0; border: 0; }
|
||||
.topbar { padding: 0 6px; gap: 4px; }
|
||||
.topbar .brand .brand-name { display: none; }
|
||||
.searchbar .search-input { height: 40px; }
|
||||
.topbar-actions .hide-mobile { display: none; }
|
||||
.msg-row { padding-right: 8px; }
|
||||
.mail-list { padding-bottom: 72px; }
|
||||
.composer { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; position: fixed; inset: 0; }
|
||||
.composer.minimized { height: 44px; width: 100vw; top: auto; bottom: 0; }
|
||||
.composer-dock { right: 0; }
|
||||
.dialog { max-height: calc(100vh - 16px); }
|
||||
.fab { display: flex; position: fixed; right: 18px; bottom: 78px; width: 56px; height: 56px; border-radius: 18px; background: var(--accent); color: var(--accent-fg); align-items: center; justify-content: center; box-shadow: var(--shadow-2); z-index: 700; }
|
||||
.mobile-tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; height: 60px; background: var(--bg-elev); border-top: 1px solid var(--border); z-index: 700; padding-bottom: env(safe-area-inset-bottom); }
|
||||
.mobile-tabbar a { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--fg-muted); text-decoration: none; font-size: 11px; }
|
||||
.mobile-tabbar a.active { color: var(--accent); }
|
||||
.main { padding-bottom: 60px; }
|
||||
.thread-subject { padding: 14px 16px 8px; }
|
||||
.message { margin: 0 8px 8px; }
|
||||
.message-head { padding: 10px 12px; }
|
||||
.message-body { padding: 4px 12px 12px; }
|
||||
.thread-toolbar .hide-mobile { display: none; }
|
||||
.list-toolbar .hide-mobile { display: none; }
|
||||
.week-head .wh-day .dnum { width: 32px; height: 32px; font-size: 1.1em; }
|
||||
.cal-toolbar h2 { font-size: 1em; }
|
||||
.contact-detail { padding: 16px 16px 80px; }
|
||||
.files-table .hide-mobile { display: none; }
|
||||
.event-popover { width: calc(100vw - 24px); }
|
||||
}
|
||||
@media (hover: none) {
|
||||
.msg-row .msg-check { opacity: 1; }
|
||||
.msg-row .msg-actions { display: none !important; }
|
||||
.msg-row:hover .msg-meta .msg-date { display: inline; }
|
||||
.nav-item .nav-more, .cal-list-item .nav-more { opacity: 1; }
|
||||
}
|
||||
@media print {
|
||||
.topbar, .sidebar, .mail-list-pane, .thread-toolbar, .composer-dock, .toast-host, .mobile-tabbar, .fab, .reply-box, .thread-actions, .quote-toggle { display: none !important; }
|
||||
.app, .app-body, .main, .mail-layout, .mail-reading-pane, .thread-view, .thread-scroll { display: block !important; height: auto !important; overflow: visible !important; grid-template-columns: 1fr !important; border: 0 !important; }
|
||||
.message { break-inside: avoid; border: 1px solid #ccc; }
|
||||
.print-only { display: block; }
|
||||
body { background: #fff; color: #000; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
direction: "vertical" | "horizontal"; // vertical = a vertical bar that resizes width
|
||||
onResize: (delta: number) => void;
|
||||
onEnd?: () => void;
|
||||
onReset?: () => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
|
||||
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Props) {
|
||||
const last = useRef(0);
|
||||
const active = useRef(false);
|
||||
return (
|
||||
<div
|
||||
className={`splitter ${direction}`}
|
||||
role="separator"
|
||||
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
|
||||
aria-label={ariaLabel ?? "Resize panes"}
|
||||
tabIndex={0}
|
||||
onDoubleClick={onReset}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
active.current = true;
|
||||
last.current = direction === "vertical" ? e.clientX : e.clientY;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.body.style.cursor = direction === "vertical" ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!active.current) return;
|
||||
const pos = direction === "vertical" ? e.clientX : e.clientY;
|
||||
const delta = pos - last.current;
|
||||
if (delta) {
|
||||
last.current = pos;
|
||||
onResize(delta);
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!active.current) return;
|
||||
active.current = false;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
onEnd?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24);
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24);
|
||||
}}
|
||||
>
|
||||
<span className="splitter-grip" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Tab" && ref.current) {
|
||||
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]');
|
||||
if (!focusables.length) return;
|
||||
const first = focusables[0]!;
|
||||
const last = focusables[focusables.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
// autofocus first input
|
||||
window.setTimeout(() => {
|
||||
const el = ref.current?.querySelector<HTMLElement>("[autofocus],input,textarea,select,button.btn-primary");
|
||||
el?.focus();
|
||||
}, 10);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className={`dialog ${size} ${className ?? ""}`} role="dialog" aria-modal="true" ref={ref}>
|
||||
{title !== undefined && (
|
||||
<div className="dialog-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-body">{children}</div>
|
||||
{footer && <div className="dialog-foot">{footer}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Imperative confirm / prompt ---------- */
|
||||
|
||||
interface ConfirmRequest {
|
||||
id: number;
|
||||
kind: "confirm" | "prompt";
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
resolve: (v: boolean | string | null) => void;
|
||||
}
|
||||
|
||||
const useConfirmStore = create<{ queue: ConfirmRequest[]; push(r: ConfirmRequest): void; pop(): void }>((set, get) => ({
|
||||
queue: [],
|
||||
push: (r) => set({ queue: [...get().queue, r] }),
|
||||
pop: () => set({ queue: get().queue.slice(1) }),
|
||||
}));
|
||||
|
||||
let reqId = 1;
|
||||
|
||||
export function confirmDialog(opts: { title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean }): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmStore.getState().push({ id: reqId++, kind: "confirm", ...opts, resolve: (v) => resolve(Boolean(v)) });
|
||||
});
|
||||
}
|
||||
|
||||
export function promptDialog(opts: { title: string; message?: ReactNode; defaultValue?: string; placeholder?: string; confirmLabel?: string }): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmStore.getState().push({ id: reqId++, kind: "prompt", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ConfirmHost() {
|
||||
const req = useConfirmStore((s) => s.queue[0]);
|
||||
const pop = useConfirmStore((s) => s.pop);
|
||||
const [value, setValue] = useState("");
|
||||
useEffect(() => setValue(req?.defaultValue ?? ""), [req?.id, req?.defaultValue]);
|
||||
if (!req) return null;
|
||||
const done = (v: boolean | string | null) => {
|
||||
req.resolve(v);
|
||||
pop();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done(req.kind === "prompt" ? null : false)}
|
||||
title={req.title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
|
||||
{req.cancelLabel ?? "Cancel"}
|
||||
</button>
|
||||
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
|
||||
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
|
||||
{req.kind === "prompt" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
done(value);
|
||||
}}
|
||||
>
|
||||
<input className="input" autoFocus value={value} placeholder={req.placeholder} onChange={(e) => setValue(e.target.value)} />
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { avatarColor, initials } from "@/lib/address";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { contactPhoto } from "@/lib/contacts";
|
||||
|
||||
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
|
||||
const email = typeof who === "string" ? who : (who?.email ?? "");
|
||||
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
|
||||
const photo = useContacts((s) => {
|
||||
if (!email || !s.loaded) return null;
|
||||
const c = s.lookupByEmail(email);
|
||||
return c && s.accountId ? contactPhoto(c, s.accountId) : null;
|
||||
});
|
||||
return (
|
||||
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
||||
{photo ? <img src={photo} alt="" loading="lazy" /> : initials({ name, email })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) {
|
||||
const sw = (
|
||||
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && onChange(!checked)} disabled={disabled} />
|
||||
);
|
||||
if (!label) return sw;
|
||||
return (
|
||||
<div className="switch-row">
|
||||
<div className="switch-text">
|
||||
<span>{label}</span>
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
</div>
|
||||
{sw}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ size = "md", label }: { size?: "md" | "lg"; label?: string }) {
|
||||
return (
|
||||
<div className="row" style={{ justifyContent: "center", padding: 16, gap: 10 }}>
|
||||
<span className={`spinner ${size === "lg" ? "lg" : ""}`} />
|
||||
{label && <span className="muted">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ icon, title, children }: { icon?: ReactNode; title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{icon}
|
||||
<h3>{title}</h3>
|
||||
{children && <p>{children}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaQuery(q: string): boolean {
|
||||
const [m, setM] = useState(() => window.matchMedia(q).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(q);
|
||||
const fn = () => setM(mq.matches);
|
||||
mq.addEventListener("change", fn);
|
||||
return () => mq.removeEventListener("change", fn);
|
||||
}, [q]);
|
||||
return m;
|
||||
}
|
||||
|
||||
export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
|
||||
export const useIsNarrow = () => useMediaQuery("(max-width: 900px)");
|
||||
|
||||
export function Kbd({ keys }: { keys: string }) {
|
||||
return (
|
||||
<span className="keys">
|
||||
{keys.split(" ").map((k, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="muted" style={{ margin: "0 3px" }}>then</span>}
|
||||
{k.split("+").map((p, j) => (
|
||||
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
|
||||
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorSwatches({ value, onChange, colors }: { value: string | null | undefined; onChange: (c: string) => void; colors?: string[] }) {
|
||||
const list = colors ?? CALENDAR_COLORS;
|
||||
return (
|
||||
<div className="swatches">
|
||||
{list.map((c) => (
|
||||
<button key={c} type="button" className={`swatch ${value?.toLowerCase() === c ? "active" : ""}`} style={{ background: c }} onClick={() => onChange(c)} aria-label={c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CALENDAR_COLORS = ["#0f766e", "#2563eb", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0891b2", "#4b5563", "#9333ea", "#be123c"];
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface Anchor {
|
||||
x: number;
|
||||
y: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export function anchorFromEl(el: Element | null): Anchor | null {
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
interface PopoverProps {
|
||||
anchor: Anchor | null;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: "start" | "end";
|
||||
/** Prefer opening below (default) or above. */
|
||||
side?: "bottom" | "top" | "right";
|
||||
width?: number | string;
|
||||
style?: CSSProperties;
|
||||
closeOnClick?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** Generic anchored popover rendered in a portal; closes on outside click / Escape. */
|
||||
export function Popover({ anchor, onClose, children, className, align = "start", side = "bottom", width, style, closeOnClick = true, role = "menu" }: PopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return;
|
||||
const el = ref.current;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const aw = anchor.w ?? 0;
|
||||
const ah = anchor.h ?? 0;
|
||||
let left = align === "end" ? anchor.x + aw - rect.width : anchor.x;
|
||||
let top = side === "top" ? anchor.y - rect.height - 4 : anchor.y + ah + 4;
|
||||
if (side === "right") {
|
||||
left = anchor.x + aw + 4;
|
||||
top = anchor.y;
|
||||
}
|
||||
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
|
||||
if (left < 8) left = 8;
|
||||
let maxHeight = Math.min(vh - 16, 560);
|
||||
if (top + rect.height > vh - 8) {
|
||||
// flip above if there is room, else clamp
|
||||
const above = anchor.y - rect.height - 4;
|
||||
if (above >= 8 && side !== "right") top = above;
|
||||
else {
|
||||
top = Math.max(8, vh - rect.height - 8);
|
||||
maxHeight = vh - top - 8;
|
||||
}
|
||||
}
|
||||
if (top < 8) top = 8;
|
||||
setPos({ left, top, maxHeight });
|
||||
}, [anchor, align, side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const onScroll = () => onClose();
|
||||
// Defer so the opening click doesn't immediately close.
|
||||
const t = window.setTimeout(() => {
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
}, [anchor, onClose]);
|
||||
|
||||
if (!anchor) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role={role}
|
||||
className={`popover ${className ?? ""}`}
|
||||
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
|
||||
onClick={(e) => {
|
||||
if (closeOnClick && (e.target as HTMLElement).closest(".menu-item")) onClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export interface MenuItemProps {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
kbd?: string;
|
||||
active?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
|
||||
return (
|
||||
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
|
||||
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
|
||||
<span className="grow truncate">{label}</span>
|
||||
{kbd && <span className="menu-kbd">{kbd}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSep() {
|
||||
return <div className="menu-sep" />;
|
||||
}
|
||||
|
||||
export function MenuTitle({ children }: { children: ReactNode }) {
|
||||
return <div className="menu-title">{children}</div>;
|
||||
}
|
||||
|
||||
/** Hook to manage a menu anchored to a trigger element. */
|
||||
export function useMenu() {
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
return {
|
||||
anchor,
|
||||
open: (e: { currentTarget: Element } | Element) => setAnchor(anchorFromEl("currentTarget" in e ? e.currentTarget : e)),
|
||||
openAt: (x: number, y: number) => setAnchor({ x, y, w: 0, h: 0 }),
|
||||
close: () => setAnchor(null),
|
||||
isOpen: anchor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Simple tooltip via title-like hover with delay. */
|
||||
export function Tooltip({ text, children }: { text: string; children: ReactNode }) {
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
return (
|
||||
<span
|
||||
style={{ display: "inline-flex" }}
|
||||
onMouseEnter={(e) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
timer.current = window.setTimeout(() => setPos({ x: r.left + r.width / 2, y: r.bottom + 6 }), 500);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
setPos(null);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
setPos(null);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{pos && createPortal(<div className="tooltip" style={{ left: Math.max(8, Math.min(pos.x, window.innerWidth - 8)), top: pos.y, transform: "translateX(-50%)" }}>{text}</div>, document.body)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { create } from "zustand";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: "info" | "error" | "success";
|
||||
action?: { label: string; onClick: () => void | Promise<void> };
|
||||
duration: number;
|
||||
progress?: boolean;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
push(t: Omit<Toast, "id">): number;
|
||||
dismiss(id: number): void;
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
const timers = new Map<number, number>();
|
||||
|
||||
export const useToasts = create<ToastState>((set, get) => ({
|
||||
toasts: [],
|
||||
push(t) {
|
||||
const id = counter++;
|
||||
set({ toasts: [...get().toasts.slice(-3), { ...t, id }] });
|
||||
if (t.duration > 0) {
|
||||
const timer = window.setTimeout(() => get().dismiss(id), t.duration);
|
||||
timers.set(id, timer);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
dismiss(id) {
|
||||
const t = timers.get(id);
|
||||
if (t) window.clearTimeout(t);
|
||||
timers.delete(id);
|
||||
set({ toasts: get().toasts.filter((x) => x.id !== id) });
|
||||
},
|
||||
}));
|
||||
|
||||
export const toast = {
|
||||
show(message: string, opts: { action?: Toast["action"]; duration?: number; kind?: Toast["kind"]; progress?: boolean } = {}): number {
|
||||
return useToasts.getState().push({ message, kind: opts.kind ?? "info", action: opts.action, duration: opts.duration ?? (opts.action ? 7000 : 4000), progress: opts.progress });
|
||||
},
|
||||
success(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
|
||||
return toast.show(message, { ...opts, kind: "success" });
|
||||
},
|
||||
error(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
|
||||
return toast.show(message, { ...opts, kind: "error", duration: opts.duration ?? 8000 });
|
||||
},
|
||||
dismiss(id: number) {
|
||||
useToasts.getState().dismiss(id);
|
||||
},
|
||||
};
|
||||
|
||||
export function ToastHost() {
|
||||
const toasts = useToasts((s) => s.toasts);
|
||||
const dismiss = useToasts((s) => s.dismiss);
|
||||
if (!toasts.length) return null;
|
||||
return (
|
||||
<div className="toast-host" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast toast-${t.kind}`}>
|
||||
<span className="toast-msg">{t.message}</span>
|
||||
{t.action && (
|
||||
<button
|
||||
className="toast-action"
|
||||
onClick={() => {
|
||||
void t.action!.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button className="toast-close" aria-label="Dismiss" onClick={() => dismiss(t.id)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, PenSquare, Settings, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { CAP } from "@/jmap/client";
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location, navigate] = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const pushConnected = useSession((s) => s.pushConnected);
|
||||
const session = useSession((s) => s.session);
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const setAccount = useSession((s) => s.setAccount);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const acctMenu = useMenu();
|
||||
const section = location.split("/")[1] || "mail";
|
||||
|
||||
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
|
||||
useEffect(() => setDrawer(false), [location]);
|
||||
|
||||
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("compose") === "new") {
|
||||
openCompose();
|
||||
navigate("/mail", { replace: true });
|
||||
}
|
||||
const mailto = params.get("mailto");
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.replace(/^mailto:/, "").split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((e) => ({ name: null, email: e.trim() })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
navigate("/mail", { replace: true });
|
||||
}
|
||||
}, [openCompose, navigate]);
|
||||
|
||||
const accounts = session ? Object.entries(session.accounts) : [];
|
||||
const mailAccounts = accounts.filter(([, a]) => CAP.mail in (a.accountCapabilities ?? {}));
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<button className="icon-btn" aria-label="Menu" onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
<Link href="/mail" className="brand">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<span className="brand-name">
|
||||
ihasmail{mailAccounts.length > 1 ? "" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
<SearchBar />
|
||||
<div className="topbar-actions">
|
||||
<span className="push-dot hide-mobile" title={pushConnected ? "Live updates connected" : "Live updates disconnected (polling)"} aria-hidden="true">
|
||||
<span className={`push-dot ${pushConnected ? "on" : ""}`} />
|
||||
</span>
|
||||
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label="Account">
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
</button>
|
||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} />
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }} className="truncate">
|
||||
{session?.username}
|
||||
</div>
|
||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{mailAccounts.length > 1 && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle>Accounts</MenuTitle>
|
||||
{mailAccounts.map(([id, a]) => (
|
||||
<MenuItem key={id} checked={id === accountId} label={a.name} onClick={() => setAccount(id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Settings size={16} />} label="Settings" onClick={() => navigate("/settings")} />
|
||||
<MenuItem icon={<RefreshCw size={16} />} label="Refresh" onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label="Sign out" onClick={() => void logout()} />
|
||||
</Popover>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
<button
|
||||
className="compose-btn"
|
||||
onClick={() => {
|
||||
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
|
||||
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
|
||||
else openCompose();
|
||||
}}
|
||||
>
|
||||
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span>
|
||||
</button>
|
||||
<div className="sidebar-scroll">
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
{section === "calendar" && <CalendarSidebar />}
|
||||
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
|
||||
{section === "files" && <div className="nav-section"><span>Files</span></div>}
|
||||
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label="Go to">
|
||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label="Mail" active={section === "mail" || section === "search"} />
|
||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label="Calendar" active={section === "calendar"} />
|
||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label="Contacts" active={section === "contacts"} />
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
{(section === "mail" || section === "search") && !location.split("/")[3] && (
|
||||
<button className="fab" aria-label="Compose" onClick={() => openCompose()}>
|
||||
<PenSquare size={24} />
|
||||
</button>
|
||||
)}
|
||||
<nav className="mobile-tabbar" aria-label="Sections">
|
||||
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
|
||||
<Mail size={22} />
|
||||
Mail
|
||||
</Link>
|
||||
<Link href="/calendar" className={section === "calendar" ? "active" : ""}>
|
||||
<Calendar size={22} />
|
||||
Calendar
|
||||
</Link>
|
||||
<Link href="/contacts" className={section === "contacts" ? "active" : ""}>
|
||||
<Users size={22} />
|
||||
Contacts
|
||||
</Link>
|
||||
<Link href="/files" className={section === "files" ? "active" : ""}>
|
||||
<FolderOpen size={22} />
|
||||
Files
|
||||
</Link>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
<ShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||
return (
|
||||
<Link href={href} className={`module-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||
{icon}
|
||||
<span className="module-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaBar() {
|
||||
const quotas = useMail((s) => s.quotas);
|
||||
const q = quotas.find((x) => x.resourceType === "octets" && x.types.includes("Email")) ?? quotas.find((x) => x.resourceType === "octets");
|
||||
if (!q || !q.hardLimit) return null;
|
||||
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
|
||||
return (
|
||||
<div className="quota" title={`${formatSize(q.used)} of ${formatSize(q.hardLimit)} used`}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>
|
||||
{formatSize(q.used)} of {formatSize(q.hardLimit)}
|
||||
</span>
|
||||
<ChevronsUpDown size={12} style={{ opacity: 0 }} />
|
||||
</div>
|
||||
<div className="quota-bar">
|
||||
<span className={pct > 95 ? "danger" : pct > 80 ? "warn" : ""} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { ApiError } from "@/jmap/client";
|
||||
|
||||
export function LoginPage() {
|
||||
const login = useSession((s) => s.login);
|
||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [showTotp, setShowTotp] = useState(false);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username.trim(), password, totp.trim(), remember);
|
||||
localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "invalid_credentials") {
|
||||
setError(showTotp ? "Invalid credentials or verification code." : "Invalid username or password.");
|
||||
if (!showTotp && password) setShowTotp(true);
|
||||
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
|
||||
else setError(err.message || "Could not sign in.");
|
||||
} else setError("Network error. Please check your connection.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="logo">
|
||||
<img src="/img/logo.png" alt="" width={120} height={113} />
|
||||
<p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="error-box mb-16" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="u">Email or username</label>
|
||||
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="p">Password</label>
|
||||
<div className="pw-wrap">
|
||||
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
|
||||
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
|
||||
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showTotp ? (
|
||||
<div className="field">
|
||||
<label htmlFor="t">Two-factor code</label>
|
||||
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
|
||||
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
|
||||
<ShieldCheck size={16} /> I have a two-factor code
|
||||
</button>
|
||||
)}
|
||||
<label className="check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>Keep me signed in on this device</span>
|
||||
</label>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<p className="foot">
|
||||
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
|
||||
export function SearchBar() {
|
||||
const [location, navigate] = useLocation();
|
||||
const search = useSearch();
|
||||
const [q, setQ] = useState("");
|
||||
const [adv, setAdv] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const [advFields, setAdvFields] = useState({ from: "", to: "", subject: "", words: "", hasAttachment: false, unread: false, folder: "", after: "", before: "" });
|
||||
|
||||
// Sync from URL when on /search
|
||||
useEffect(() => {
|
||||
if (location.startsWith("/search")) {
|
||||
const params = new URLSearchParams(search);
|
||||
setQ(params.get("q") ?? "");
|
||||
} else setQ("");
|
||||
}, [location, search]);
|
||||
|
||||
useEffect(() => keyboard.pushScope("search", [{ keys: "/", description: "Search mail", group: "Navigation", handler: () => inputRef.current?.focus() }]), []);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
setAdv(false);
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const applyAdvanced = () => {
|
||||
const parts: string[] = [];
|
||||
const f = advFields;
|
||||
if (f.from) parts.push(`from:${quote(f.from)}`);
|
||||
if (f.to) parts.push(`to:${quote(f.to)}`);
|
||||
if (f.subject) parts.push(`subject:${quote(f.subject)}`);
|
||||
if (f.words) parts.push(f.words);
|
||||
if (f.hasAttachment) parts.push("has:attachment");
|
||||
if (f.unread) parts.push("is:unread");
|
||||
if (f.folder) parts.push(`in:${quote(f.folder)}`);
|
||||
if (f.after) parts.push(`after:${f.after}`);
|
||||
if (f.before) parts.push(`before:${f.before}`);
|
||||
const query = parts.join(" ");
|
||||
setQ(query);
|
||||
setAdv(false);
|
||||
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="searchbar" role="search" onSubmit={submit}>
|
||||
<div className="search-input">
|
||||
<Search size={18} className="muted" />
|
||||
<input ref={inputRef} type="search" placeholder="Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search mail" enterKeyHint="search" />
|
||||
{q && (
|
||||
<button type="button" className="icon-btn sm" aria-label="Clear" onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label="Advanced search" title="Advanced search" onClick={() => setAdv((v) => !v)}>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{adv && (
|
||||
<div className="search-panel">
|
||||
<div className="grid">
|
||||
<label className="field"><span className="label">From</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">To</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Subject</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Has the words</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
|
||||
<label className="field"><span className="label">Folder</span>
|
||||
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
|
||||
<option value="">All mail</option>
|
||||
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="field"><span className="label">Date</span>
|
||||
<div className="row"><input className="input sm" type="date" value={advFields.after} onChange={(e) => setAdvFields({ ...advFields, after: e.target.value })} /><span className="muted">to</span><input className="input sm" type="date" value={advFields.before} onChange={(e) => setAdvFields({ ...advFields, before: e.target.value })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
|
||||
<div className="row gap-16">
|
||||
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> Has attachment</label>
|
||||
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return /\s/.test(s) ? `"${s.replace(/"/g, "")}"` : s;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Kbd } from "@/ui/misc";
|
||||
|
||||
export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) {
|
||||
const [, navigate] = useLocation();
|
||||
useEffect(() => {
|
||||
const go = (role: string) => () => {
|
||||
const id = useMail.getState().roleId(role as never);
|
||||
if (id) navigate(`/mail/${id}`);
|
||||
};
|
||||
return keyboard.pushScope("global", [
|
||||
{ keys: "c", description: "Compose new message", group: "Mail", handler: () => void useCompose.getState().open() },
|
||||
{ keys: "?", description: "Show keyboard shortcuts", group: "Navigation", handler: onHelp },
|
||||
{ keys: "g i", description: "Go to Inbox", group: "Navigation", handler: go("inbox") },
|
||||
{ keys: "g s", description: "Go to Starred", group: "Navigation", handler: () => navigate("/search?q=is:starred") },
|
||||
{ keys: "g t", description: "Go to Sent", group: "Navigation", handler: go("sent") },
|
||||
{ keys: "g d", description: "Go to Drafts", group: "Navigation", handler: go("drafts") },
|
||||
{ keys: "g a", description: "Go to All mail / Archive", group: "Navigation", handler: () => { const id = useMail.getState().roleId("all") ?? useMail.getState().roleId("archive"); if (id) navigate(`/mail/${id}`); } },
|
||||
{ keys: "g l", description: "Go to Calendar", group: "Navigation", handler: () => navigate("/calendar") },
|
||||
{ keys: "g c", description: "Go to Contacts", group: "Navigation", handler: () => navigate("/contacts") },
|
||||
{ keys: "g f", description: "Go to Files", group: "Navigation", handler: () => navigate("/files") },
|
||||
{ keys: "g k", description: "Go to Settings", group: "Navigation", handler: () => navigate("/settings") },
|
||||
]);
|
||||
}, [navigate, onHelp]);
|
||||
}
|
||||
|
||||
export function ShortcutsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const list = useMemo(() => (open ? keyboard.list() : []), [open]);
|
||||
const groups = useMemo(() => {
|
||||
const g = new Map<string, typeof list>();
|
||||
for (const b of list) {
|
||||
const arr = g.get(b.group) ?? [];
|
||||
arr.push(b);
|
||||
g.set(b.group, arr);
|
||||
}
|
||||
return [...g.entries()];
|
||||
}, [list]);
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title="Keyboard shortcuts" size="lg">
|
||||
<div className="shortcut-grid">
|
||||
{groups.map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
{items.map((b) => (
|
||||
<div key={b.keys} className="shortcut-row">
|
||||
<span>{b.description}</span>
|
||||
<Kbd keys={b.keys} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatTime } from "@/lib/format";
|
||||
|
||||
export type CalendarContext =
|
||||
| { kind: "event"; inst: EventInstance; anchor: Anchor }
|
||||
| { kind: "slot"; start: Date; end: Date; allDay: boolean; anchor: Anchor };
|
||||
|
||||
interface Props {
|
||||
ctx: CalendarContext;
|
||||
onClose: () => void;
|
||||
onOpen: (inst: EventInstance, anchor: Anchor) => void;
|
||||
onEdit: (inst: EventInstance) => void;
|
||||
onCreate: (start: Date, end: Date, allDay: boolean) => void;
|
||||
}
|
||||
|
||||
/** Resolve the display colour of an event: explicit colour → category colour → calendar colour. */
|
||||
export function eventColor(ev: CalendarEvent, calendarColor: string | null | undefined, categories: Array<{ name: string; color: string }>): string {
|
||||
if (ev.color) return ev.color;
|
||||
const cat = categoryOf(ev, categories);
|
||||
if (cat) return cat.color;
|
||||
return calendarColor ?? "var(--accent)";
|
||||
}
|
||||
|
||||
export function categoryOf(ev: CalendarEvent, categories: Array<{ name: string; color: string }>): { name: string; color: string } | undefined {
|
||||
const names = Object.keys(ev.categories ?? {});
|
||||
for (const n of names) {
|
||||
const c = categories.find((x) => x.name.toLowerCase() === n.toLowerCase());
|
||||
if (c) return c;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: Props) {
|
||||
const cal = useCalendar();
|
||||
const [, navigate] = useLocation();
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
|
||||
if (ctx.kind === "slot") {
|
||||
const { start, end, allDay } = ctx;
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
||||
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
||||
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
||||
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const { inst } = ctx;
|
||||
const ev = inst.event;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
||||
const currentCat = categoryOf(ev, categories);
|
||||
const participants = Object.keys(ev.participants ?? {}).length;
|
||||
|
||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||
try {
|
||||
await cal.updateEvent(baseId, p, false);
|
||||
toast.success(msg);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
|
||||
const setCategory = (cat: { name: string; color: string } | null) => {
|
||||
const categoriesPatch = cat ? { [cat.name]: true } : null;
|
||||
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
|
||||
};
|
||||
const duplicate = async () => {
|
||||
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
|
||||
try {
|
||||
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
toast.success("Event duplicated");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const del = async () => {
|
||||
onClose();
|
||||
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
|
||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants > 1);
|
||||
toast.success("Event deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
|
||||
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
|
||||
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
|
||||
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
|
||||
{canEdit && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Tag size={12} /> Category</span></MenuTitle>
|
||||
{categories.map((c) => (
|
||||
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
|
||||
))}
|
||||
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
|
||||
<MenuSep />
|
||||
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
|
||||
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
|
||||
{CALENDAR_COLORS.map((c) => (
|
||||
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
|
||||
))}
|
||||
</div>
|
||||
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import type { Calendar } from "@/jmap/types";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { ColorSwatches } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
|
||||
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const [name, setName] = useState(calendar.name ?? "");
|
||||
const [color, setColor] = useState(calendar.color ?? "#0f766e");
|
||||
const [description, setDescription] = useState(calendar.description ?? "");
|
||||
const [tz, setTz] = useState(calendar.timeZone ?? "");
|
||||
const [avail, setAvail] = useState<Calendar["includeInAvailability"]>(calendar.includeInAvailability ?? "all");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async () => {
|
||||
if (!name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data: Partial<Calendar> = { name: name.trim(), color, description: description || null, timeZone: tz || null, includeInAvailability: avail };
|
||||
if (calendar.id) await cal.updateCalendar(calendar.id, data);
|
||||
else await cal.createCalendar(data);
|
||||
toast.success("Calendar saved");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>Save</button></>}>
|
||||
<div className="field"><label>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>Color</label><ColorSwatches value={color} onChange={setColor} /></div>
|
||||
<div className="field"><label>Description</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||
<div className="field"><label>Time zone</label>
|
||||
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
||||
<option value="">Default ({browserTimeZone})</option>
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Free/busy</label>
|
||||
<select className="select" value={avail} onChange={(e) => setAvail(e.target.value as Calendar["includeInAvailability"])}>
|
||||
<option value="all">Count all events as busy</option>
|
||||
<option value="attending">Only events I'm attending</option>
|
||||
<option value="none">Don't include in availability</option>
|
||||
</select>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Calendar } from "@/jmap/types";
|
||||
import { CalendarDialog } from "./CalendarDialog";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
export function CalendarSidebar() {
|
||||
const [location, navigate] = useLocation();
|
||||
const cal = useCalendar();
|
||||
const weekStart = useSettings((s) => s.settings.weekStart);
|
||||
const parts = location.split("/");
|
||||
const view = parts[2] || "week";
|
||||
const dateStr = parts[3];
|
||||
const selected = useMemo(() => (dateStr ? new Date(`${dateStr}T00:00:00`) : new Date()), [dateStr]);
|
||||
const [anchor, setAnchor] = useState(() => startOfDay(selected));
|
||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||
const menu = useMenu();
|
||||
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
|
||||
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
||||
const [share, setShare] = useState<Calendar | null>(null);
|
||||
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
|
||||
const dow = useMemo(() => {
|
||||
const names = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
|
||||
}, [weekStart]);
|
||||
|
||||
if (!cal.available) return null;
|
||||
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div style={{ padding: "4px 8px" }}>
|
||||
<div className="mini-cal">
|
||||
<div className="mc-head">
|
||||
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
|
||||
<span>{formatMonthYear(anchor)}</span>
|
||||
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div className="mc-grid">
|
||||
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
|
||||
{grid.map((d) => (
|
||||
<div key={d.toISOString()} className={`mc-day ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""} ${isSameDay(d, selected) ? "selected" : ""} ${instances.some((i) => i.start < new Date(d.getTime() + 86400000) && i.end > d) ? "has-events" : ""}`} onClick={() => navigate(`/calendar/${view === "month" ? "day" : view}/${toLocalDateOnly(d)}`)}>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-section" style={{ paddingLeft: 4 }}>
|
||||
<span>My calendars</span>
|
||||
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
|
||||
</div>
|
||||
{calendars.map((c) => (
|
||||
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||
<span className="cal-name">{c.name}</span>
|
||||
{c.isDefault && <Star size={12} className="faint" />}
|
||||
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
||||
{menuCal && (
|
||||
<>
|
||||
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
|
||||
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
|
||||
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { Empty, useIsMobile } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||
import type { Anchor } from "@/ui/popover";
|
||||
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
|
||||
|
||||
type View = "month" | "week" | "day" | "agenda";
|
||||
const HOUR_H = 48;
|
||||
|
||||
export function CalendarView({ view: viewParam, date }: { view?: string; date?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const isMobile = useIsMobile();
|
||||
const view: View = (["month", "week", "day", "agenda"].includes(viewParam ?? "") ? viewParam : settings.calendarDefaultView) as View;
|
||||
const anchor = useMemo(() => {
|
||||
const d = date ? new Date(`${date}T00:00:00`) : new Date();
|
||||
return Number.isNaN(d.getTime()) ? startOfDay(new Date()) : startOfDay(d);
|
||||
}, [date]);
|
||||
const [popover, setPopover] = useState<{ inst: EventInstance; anchor: Anchor } | null>(null);
|
||||
const [editor, setEditor] = useState<EditorInit | null>(null);
|
||||
const [ctx, setCtx] = useState<CalendarContext | null>(null);
|
||||
const weekStart = settings.weekStart;
|
||||
const effectiveView: View = isMobile && view === "week" ? "day" : view;
|
||||
|
||||
// Range to load
|
||||
const range = useMemo(() => {
|
||||
if (effectiveView === "month") {
|
||||
const g = monthGrid(anchor, weekStart);
|
||||
return { start: g[0]!, end: addDays(g[41]!, 1) };
|
||||
}
|
||||
if (effectiveView === "week") {
|
||||
const s = startOfWeek(anchor, weekStart);
|
||||
return { start: s, end: addDays(s, 7) };
|
||||
}
|
||||
if (effectiveView === "day") return { start: anchor, end: addDays(anchor, 1) };
|
||||
return { start: anchor, end: addDays(anchor, 60) };
|
||||
}, [effectiveView, anchor, weekStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cal.available) void cal.loadRange(range.start, range.end);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cal.available, range.start.getTime(), range.end.getTime()]);
|
||||
|
||||
const go = useCallback((v: View, d: Date) => navigate(`/calendar/${v}/${toLocalDateOnly(d)}`), [navigate]);
|
||||
const step = (n: number) => {
|
||||
if (effectiveView === "month") go(view, addMonths(anchor, n));
|
||||
else if (effectiveView === "week") go(view, addDays(anchor, 7 * n));
|
||||
else if (effectiveView === "day") go(view, addDays(anchor, n));
|
||||
else go(view, addDays(anchor, 30 * n));
|
||||
};
|
||||
|
||||
const openNew = useCallback(
|
||||
(start?: Date, end?: Date, allDay = false) => {
|
||||
const s = start ?? roundToNext(new Date(), 30);
|
||||
const e = end ?? new Date(s.getTime() + settings.defaultEventDuration * 60_000);
|
||||
setEditor({ start: s, end: e, allDay });
|
||||
},
|
||||
[settings.defaultEventDuration],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onNew = () => openNew();
|
||||
window.addEventListener("ihm:new-event", onNew);
|
||||
return () => window.removeEventListener("ihm:new-event", onNew);
|
||||
}, [openNew]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
keyboard.pushScope("calendar", [
|
||||
{ keys: "t", description: "Today", group: "Calendar", handler: () => go(view, new Date()) },
|
||||
{ keys: "n", description: "Next period", group: "Calendar", handler: () => step(1) },
|
||||
{ keys: "p", description: "Previous period", group: "Calendar", handler: () => step(-1) },
|
||||
{ keys: "d", description: "Day view", group: "Calendar", handler: () => go("day", anchor) },
|
||||
{ keys: "w", description: "Week view", group: "Calendar", handler: () => go("week", anchor) },
|
||||
{ keys: "m", description: "Month view", group: "Calendar", handler: () => go("month", anchor) },
|
||||
{ keys: "a", description: "Agenda view", group: "Calendar", handler: () => go("agenda", anchor) },
|
||||
{ keys: "c", description: "New event", group: "Calendar", handler: () => openNew() },
|
||||
]),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view, anchor, openNew],
|
||||
);
|
||||
|
||||
if (!cal.available) {
|
||||
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title="Calendar is not available">This account does not have the JMAP calendars capability.</Empty></div>;
|
||||
}
|
||||
|
||||
const title =
|
||||
effectiveView === "month" ? formatMonthYear(anchor)
|
||||
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
|
||||
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
|
||||
const onEvent = (inst: EventInstance, el: Element) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPopover({ inst, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } });
|
||||
};
|
||||
const onEventContext = (inst: EventInstance, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPopover(null);
|
||||
setCtx({ kind: "event", inst, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
|
||||
};
|
||||
const onSlotContext = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtx({ kind: "slot", start, end, allDay, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cal-main">
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
|
||||
<button className="icon-btn sm" onClick={() => step(-1)} aria-label="Previous"><ChevronLeft size={18} /></button>
|
||||
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
|
||||
<h2 className="truncate">{title}</h2>
|
||||
<span className="spacer" />
|
||||
{cal.loading && <span className="spinner" />}
|
||||
<div className="view-switch">
|
||||
{(["day", "week", "month", "agenda"] as View[]).filter((v) => !(isMobile && v === "week")).map((v) => (
|
||||
<button key={v} className={effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{v[0]!.toUpperCase() + v.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> Event</button>}
|
||||
</div>
|
||||
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
|
||||
{effectiveView === "month" && <MonthView anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
|
||||
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
|
||||
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
|
||||
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
|
||||
{isMobile && <button className="fab" aria-label="New event" onClick={() => openNew()}><Plus size={24} /></button>}
|
||||
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
|
||||
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Month ---------------- */
|
||||
|
||||
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
|
||||
type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void;
|
||||
|
||||
function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void }) {
|
||||
const cal = useCalendar();
|
||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
|
||||
const maxPer = 4;
|
||||
return (
|
||||
<div className="month-grid">
|
||||
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
|
||||
{weeks.map((days, wi) => (
|
||||
<div key={wi} className="week-row">
|
||||
{days.map((d) => {
|
||||
const dayEnd = addDays(d, 1);
|
||||
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
|
||||
const shown = evs.slice(0, maxPer);
|
||||
return (
|
||||
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
|
||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusClass(i: EventInstance): string {
|
||||
const ev = i.event;
|
||||
const mine = useCalendar.getState().identities;
|
||||
const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]);
|
||||
let my: string | undefined;
|
||||
for (const p of Object.values(ev.participants ?? {})) {
|
||||
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
|
||||
if (addrs.some((a) => ids.includes(a))) my = p.participationStatus;
|
||||
}
|
||||
if (ev.status === "cancelled") return "cancelled";
|
||||
if (my === "declined") return "declined";
|
||||
if (my === "tentative" || my === "needs-action" || ev.status === "tentative") return "tentative";
|
||||
return "";
|
||||
}
|
||||
|
||||
function useEventColor() {
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories);
|
||||
}
|
||||
|
||||
function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void }) {
|
||||
const color = useEventColor()(inst);
|
||||
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
|
||||
return (
|
||||
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)}`} style={{ background: color, borderColor: color }} onClick={(e) => { e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
|
||||
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
|
||||
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
|
||||
<span className="truncate">{inst.event.title || "(untitled)"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Week / Day ---------------- */
|
||||
|
||||
function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; workStart: number; workEnd: number }) {
|
||||
const cal = useCalendar();
|
||||
const colorOf = useEventColor();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const start = days[0]!;
|
||||
const end = addDays(days[days.length - 1]!, 1);
|
||||
const instances = cal.instancesIn(start, end);
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// scroll to 7am-ish on mount
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, (Math.min(workStart, 8) - 0.5) * HOUR_H);
|
||||
}, [workStart, days.length]);
|
||||
|
||||
const allDay = (d: Date) => instances.filter((i) => (i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
|
||||
const timed = (d: Date) => instances.filter((i) => !(i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
|
||||
|
||||
const minutesFromEvent = (e: React.MouseEvent, col: HTMLElement) => {
|
||||
const r = col.getBoundingClientRect();
|
||||
const y = e.clientY - r.top + 0; // col is full height
|
||||
return Math.max(0, Math.min(24 * 60, Math.round((y / HOUR_H) * 60 / 15) * 15));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="week-view" style={{ "--cols": days.length } as React.CSSProperties}>
|
||||
<div className="week-head">
|
||||
<div />
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
|
||||
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
|
||||
<div className="dnum">{d.getDate()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="week-allday">
|
||||
<div className="ad-label">all-day</div>
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
|
||||
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="week-scroll" ref={scrollRef}>
|
||||
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
|
||||
<div className="time-col">
|
||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
|
||||
</div>
|
||||
{days.map((d) => {
|
||||
const evs = layoutOverlaps(timed(d), d);
|
||||
const today = isToday(d);
|
||||
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
|
||||
return (
|
||||
<div
|
||||
key={d.toISOString()}
|
||||
className={`day-col ${today ? "today" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest(".ev-block")) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
setDrag({ day: d, startMin: m, endMin: m + 30 });
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!drag || !isSameDay(drag.day, d)) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
setDrag({ ...drag, endMin: Math.max(drag.startMin + 15, m) });
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!drag || !isSameDay(drag.day, d)) return;
|
||||
const s = new Date(d.getTime() + drag.startMin * 60_000);
|
||||
const e2 = new Date(d.getTime() + drag.endMin * 60_000);
|
||||
setDrag(null);
|
||||
onCreate(s, e2, false);
|
||||
}}
|
||||
onMouseLeave={() => { if (drag && isSameDay(drag.day, d)) { const s = new Date(d.getTime() + drag.startMin * 60_000); const e2 = new Date(d.getTime() + drag.endMin * 60_000); setDrag(null); onCreate(s, e2, false); } }}
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest(".ev-block")) return;
|
||||
const m = minutesFromEvent(e, e.currentTarget);
|
||||
const st = new Date(d.getTime() + Math.floor(m / 30) * 30 * 60_000);
|
||||
onSlotContext(st, new Date(st.getTime() + 60 * 60_000), false, e);
|
||||
}}
|
||||
>
|
||||
<div className="work-hours" style={{ top: workStart * HOUR_H, height: Math.max(0, workEnd - workStart) * HOUR_H }} />
|
||||
{[...Array(24)].map((_, h) => <div key={h} className="hour-line" style={{ top: h * HOUR_H }} />)}
|
||||
{[...Array(24)].map((_, h) => <div key={`h${h}`} className="half-line" style={{ top: h * HOUR_H + HOUR_H / 2 }} />)}
|
||||
{today && <div className="now-line" style={{ top: nowTop }} />}
|
||||
{evs.map(({ inst, top, height, left, width }) => {
|
||||
const color = colorOf(inst);
|
||||
return (
|
||||
<div key={inst.key} className={`ev-block ${statusClass(inst)}`} style={{ top, height: Math.max(height, 18), left: `${left}%`, width: `calc(${width}% - 3px)`, background: color }} onClick={(e) => { e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}>
|
||||
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
|
||||
{height > 30 && <div className="ev-time">{formatTime(inst.start)} – {formatTime(inst.end)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{drag && isSameDay(drag.day, d) && (
|
||||
<div className="ev-block draft-new" style={{ top: (drag.startMin / 60) * HOUR_H, height: ((drag.endMin - drag.startMin) / 60) * HOUR_H, left: 0, width: "calc(100% - 3px)", background: "var(--accent)" }}>
|
||||
<div className="ev-title">(new event)</div>
|
||||
<div className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} – {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple column layout for overlapping events. */
|
||||
function layoutOverlaps(evs: EventInstance[], day: Date): Array<{ inst: EventInstance; top: number; height: number; left: number; width: number }> {
|
||||
const dayStart = day.getTime();
|
||||
const dayEnd = dayStart + DAY_MS;
|
||||
const items = evs
|
||||
.map((inst) => {
|
||||
const s = Math.max(inst.start.getTime(), dayStart);
|
||||
const e = Math.min(inst.end.getTime(), dayEnd);
|
||||
return { inst, s, e, col: 0, cols: 1 };
|
||||
})
|
||||
.sort((a, b) => a.s - b.s || b.e - a.e);
|
||||
// Greedy column assignment within clusters
|
||||
const clusters: Array<typeof items> = [];
|
||||
let cur: typeof items = [];
|
||||
let curEnd = -1;
|
||||
for (const it of items) {
|
||||
if (cur.length && it.s >= curEnd) {
|
||||
clusters.push(cur);
|
||||
cur = [];
|
||||
curEnd = -1;
|
||||
}
|
||||
cur.push(it);
|
||||
curEnd = Math.max(curEnd, it.e);
|
||||
}
|
||||
if (cur.length) clusters.push(cur);
|
||||
for (const cl of clusters) {
|
||||
const colEnds: number[] = [];
|
||||
for (const it of cl) {
|
||||
let c = colEnds.findIndex((end) => end <= it.s);
|
||||
if (c < 0) {
|
||||
c = colEnds.length;
|
||||
colEnds.push(0);
|
||||
}
|
||||
colEnds[c] = it.e;
|
||||
it.col = c;
|
||||
}
|
||||
for (const it of cl) it.cols = colEnds.length;
|
||||
}
|
||||
return items.map((it) => ({
|
||||
inst: it.inst,
|
||||
top: ((it.s - dayStart) / 3_600_000) * HOUR_H,
|
||||
height: ((it.e - it.s) / 3_600_000) * HOUR_H,
|
||||
left: (it.col / it.cols) * 100,
|
||||
width: 100 / it.cols,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------- Agenda ---------------- */
|
||||
|
||||
function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx }) {
|
||||
const cal = useCalendar();
|
||||
const colorOf = useEventColor();
|
||||
const end = addDays(start, 60);
|
||||
const instances = cal.instancesIn(start, end);
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, { day: Date; items: EventInstance[] }>();
|
||||
for (const i of instances) {
|
||||
let d = startOfDay(i.start < start ? start : i.start);
|
||||
const last = startOfDay(new Date(i.end.getTime() - 1));
|
||||
while (d <= last && d < end) {
|
||||
const k = toLocalDateOnly(d);
|
||||
const e = map.get(k) ?? { day: new Date(d), items: [] };
|
||||
e.items.push(i);
|
||||
map.set(k, e);
|
||||
d = addDays(d, 1);
|
||||
if (!i.allDay && isSameDay(i.start, i.end)) break;
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}, [instances, start, end]);
|
||||
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title="Nothing scheduled">No events in the next 60 days.</Empty>;
|
||||
return (
|
||||
<div className="agenda">
|
||||
{byDay.map(({ day, items }) => (
|
||||
<div key={day.toISOString()} className="agenda-day">
|
||||
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
|
||||
{day.toLocaleDateString(undefined, { weekday: "long" })}
|
||||
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
|
||||
</div>
|
||||
<div>
|
||||
{items.map((i) => (
|
||||
<div key={i.key + day.toISOString()} className={`agenda-ev ${statusClass(i)}`} onClick={(e) => onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}>
|
||||
<span className="ev-dot" style={{ background: colorOf(i) }} />
|
||||
<span className="ev-when">{i.allDay ? "All day" : `${formatTime(i.start)} – ${formatTime(i.end)}`}</span>
|
||||
<span className="grow truncate">{i.event.title || "(untitled)"}</span>
|
||||
{Object.values(i.event.locations ?? {})[0]?.name && <span className="hint truncate" style={{ maxWidth: 200 }}>{Object.values(i.event.locations ?? {})[0]!.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { endOfDay };
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { useCalendar, myParticipantKeys } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { ColorSwatches, Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { RecipientInput } from "../compose/RecipientInput";
|
||||
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||
import { newKey } from "@/lib/contacts";
|
||||
|
||||
export interface EditorInit {
|
||||
event?: CalendarEvent;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||
|
||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const session = useSession((s) => s.session);
|
||||
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
||||
const editing = Boolean(init.event);
|
||||
|
||||
// Load base event for recurring instances
|
||||
useEffect(() => {
|
||||
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
|
||||
else if (!init.event) setBase(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [init.event?.id]);
|
||||
|
||||
if (base === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
}
|
||||
|
||||
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
const cal = useCalendar();
|
||||
const contacts = useContacts();
|
||||
const ev = base;
|
||||
const calendars = Object.values(cal.calendars).filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn);
|
||||
const initialCal = ev ? Object.keys(ev.calendarIds)[0] : (calendars.find((c) => c.isDefault)?.id ?? calendars[0]?.id);
|
||||
const evTz = ev?.timeZone ?? settingsTz;
|
||||
const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start;
|
||||
const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end;
|
||||
|
||||
const [title, setTitle] = useState(ev?.title ?? "");
|
||||
const [calendarId, setCalendarId] = useState(initialCal ?? "");
|
||||
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
|
||||
const [start, setStart] = useState(baseStart);
|
||||
const [end, setEnd] = useState(baseEnd);
|
||||
const [tz, setTz] = useState(evTz);
|
||||
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
|
||||
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? "");
|
||||
const [description, setDescription] = useState(ev?.description ?? "");
|
||||
const [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
|
||||
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
|
||||
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
|
||||
const [color, setColor] = useState<string | null>(ev?.color ?? null);
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
|
||||
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
|
||||
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
|
||||
const [alerts, setAlerts] = useState<number[]>(() => {
|
||||
const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0);
|
||||
if (ev) return a;
|
||||
return defaultAlert >= 0 ? [defaultAlert] : [];
|
||||
});
|
||||
const myKeys = ev ? myParticipantKeys(ev, cal.identities) : [];
|
||||
const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
|
||||
Object.entries(ev?.participants ?? {})
|
||||
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
|
||||
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" }))
|
||||
.filter((a) => a.email),
|
||||
);
|
||||
const [sendInvites, setSendInvites] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
|
||||
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
|
||||
|
||||
const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0];
|
||||
const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : "");
|
||||
const myPlainEmail = myAddress.replace(/^mailto:/i, "");
|
||||
|
||||
// Free/busy lookup for attendees that are directory principals
|
||||
useEffect(() => {
|
||||
if (!attendees.length || !contacts.principalsLoaded) {
|
||||
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
|
||||
return;
|
||||
}
|
||||
const dayStart = new Date(start);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<string, BusyPeriod[]> = {};
|
||||
for (const a of attendees) {
|
||||
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
|
||||
if (!p) continue;
|
||||
try {
|
||||
out[a.email] = await cal.availability(p.id, dayStart, dayEnd);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!cancelled) setFb(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attendees.map((a) => a.email).join(","), start.getTime(), contacts.principalsLoaded]);
|
||||
|
||||
const onStartChange = (d: Date) => {
|
||||
if (Number.isNaN(d.getTime())) return;
|
||||
const dur = end.getTime() - start.getTime();
|
||||
setStart(d);
|
||||
setEnd(new Date(d.getTime() + Math.max(dur, allDay ? DAY_MS : 15 * 60_000)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!calendarId) {
|
||||
toast.error("Choose a calendar");
|
||||
return;
|
||||
}
|
||||
if (end <= start) {
|
||||
toast.error("End must be after start");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const s = allDay ? new Date(start.getFullYear(), start.getMonth(), start.getDate()) : start;
|
||||
let e = allDay ? new Date(end.getFullYear(), end.getMonth(), end.getDate()) : end;
|
||||
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
|
||||
const participants: Record<string, JSCalendarParticipant> = {};
|
||||
if (attendees.length && myAddress) {
|
||||
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false };
|
||||
for (const a of attendees) {
|
||||
// preserve existing status if the attendee was already there
|
||||
const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase());
|
||||
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true };
|
||||
}
|
||||
}
|
||||
const alertObj: Record<string, JSCalendarAlert> = {};
|
||||
for (const m of alerts) alertObj[newKey("a")] = { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: formatDuration(-m * 60), relativeTo: "start" }, action: "display" };
|
||||
const obj: Record<string, unknown> = {
|
||||
title: title.trim() || "(untitled)",
|
||||
description: description.trim() || undefined,
|
||||
showWithoutTime: allDay,
|
||||
start: allDay ? `${toLocalDateOnly(s)}T00:00:00` : dateToZonedLocal(s, tz),
|
||||
timeZone: allDay ? null : tz,
|
||||
duration: formatDuration(Math.round((e.getTime() - s.getTime()) / 1000)),
|
||||
locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined,
|
||||
virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined,
|
||||
participants: Object.keys(participants).length ? participants : undefined,
|
||||
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined,
|
||||
alerts: Object.keys(alertObj).length ? alertObj : undefined,
|
||||
useDefaultAlerts: false,
|
||||
recurrenceRules: rule ? [rule] : undefined,
|
||||
status,
|
||||
privacy,
|
||||
freeBusyStatus: freeBusy,
|
||||
color: color ?? (category ? categories.find((c) => c.name === category)?.color : undefined),
|
||||
categories: category ? { [category]: true } : undefined,
|
||||
};
|
||||
const invites = sendInvites && attendees.length > 0;
|
||||
if (ev) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
await cal.updateEvent(ev.id, patch, invites);
|
||||
toast.success("Event updated");
|
||||
} else {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites);
|
||||
toast.success(invites ? "Event created and invitations sent" : "Event created");
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const };
|
||||
const dayWindow = useMemo(() => {
|
||||
const ds = new Date(start);
|
||||
ds.setHours(0, 0, 0, 0);
|
||||
return { ds, de: new Date(ds.getTime() + DAY_MS) };
|
||||
}, [start]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||
<div className="event-form">
|
||||
{init.event?.baseEventId && <div className="info-box mb-16">This is a recurring event — changes apply to the whole series.</div>}
|
||||
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||
<div className="time-row mb-8">
|
||||
{allDay ? (
|
||||
<>
|
||||
<input className="input" type="date" value={toLocalDateOnly(start)} onChange={(e) => onStartChange(new Date(`${e.target.value}T00:00:00`))} />
|
||||
<span className="muted center">to</span>
|
||||
<input className="input" type="date" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(e) => setEnd(new Date(new Date(`${e.target.value}T00:00:00`).getTime() + DAY_MS))} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input className="input" type="datetime-local" value={toInputDateTime(start)} onChange={(e) => onStartChange(fromInputDateTime(e.target.value))} />
|
||||
<span className="muted center">to</span>
|
||||
<input className="input" type="datetime-local" value={toInputDateTime(end)} onChange={(e) => setEnd(fromInputDateTime(e.target.value))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
|
||||
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
|
||||
{!allDay && (
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title="Time zone">
|
||||
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
|
||||
<option value="weekdays">Every weekday</option>
|
||||
<option value="monthly">Monthly on day {start.getDate()}</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="row wrap" style={{ gap: 8 }}>
|
||||
<span>Repeat every</span>
|
||||
<input className="input" type="number" min={1} style={{ width: 70 }} value={customRule.interval ?? 1} onChange={(e) => setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
|
||||
<select className="select" style={{ width: "auto" }} value={customRule.frequency} onChange={(e) => setRule({ ...customRule, frequency: e.target.value as JSCalendarRecurrenceRule["frequency"], byDay: e.target.value === "weekly" ? customRule.byDay : undefined, byMonthDay: e.target.value === "monthly" ? [start.getDate()] : undefined })}>
|
||||
<option value="daily">day(s)</option><option value="weekly">week(s)</option><option value="monthly">month(s)</option><option value="yearly">year(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
{customRule.frequency === "weekly" && (
|
||||
<div className="row" style={{ gap: 4, marginTop: 8 }}>
|
||||
{WEEKDAYS.map((w) => {
|
||||
const on = customRule.byDay?.some((d) => d.day === w.key);
|
||||
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
|
||||
<span>Ends</span>
|
||||
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
|
||||
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
|
||||
</select>
|
||||
{customRule.until && <input className="input" type="date" style={{ width: "auto" }} value={customRule.until.slice(0, 10)} onChange={(e) => setRule({ ...customRule, until: `${e.target.value}T23:59:59` })} />}
|
||||
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
|
||||
</div>
|
||||
<div className="hint mt-8">{describeRule(customRule)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Calendar</label>
|
||||
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Location</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Add location" /></div>
|
||||
</div>
|
||||
<div className="field"><label>Meeting link</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder="https://meet.example.com/…" /></div>
|
||||
<div className="field">
|
||||
<label><Users size={13} /> Guests</label>
|
||||
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
|
||||
<RecipientInput value={attendees} onChange={setAttendees} placeholder="Add guests by name or email" />
|
||||
</div>
|
||||
{attendees.length > 0 && (
|
||||
<>
|
||||
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
|
||||
{Object.keys(fb).length > 0 && (
|
||||
<div className="freebusy">
|
||||
<div className="hint">Availability on {start.toLocaleDateString()}</div>
|
||||
{attendees.filter((a) => fb[a.email]).map((a) => (
|
||||
<div key={a.email} className="fb-row">
|
||||
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
|
||||
<div className="fb-bar">
|
||||
{fb[a.email]!.map((b, i) => {
|
||||
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
|
||||
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
|
||||
if (be <= bs) return null;
|
||||
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} – ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
|
||||
})}
|
||||
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="field"><label>Description</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
|
||||
<div className="field">
|
||||
<label>Reminders</label>
|
||||
<div className="alerts-list">
|
||||
{alerts.map((m, i) => (
|
||||
<div key={i} className="row">
|
||||
<select className="select" style={{ width: "auto" }} value={String(m)} onChange={(e) => setAlerts(alerts.map((x, j) => (j === i ? Number(e.target.value) : x)))}>
|
||||
{[...new Set([...ALERT_OPTIONS, m])].sort((a, b) => a - b).map((o) => <option key={o} value={o}>{o === 0 ? "At time of event" : `${humanDuration(o * 60)} before`}</option>)}
|
||||
</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label="Remove reminder"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowMore((v) => !v)}>{showMore ? "Fewer options" : "More options"}</button>
|
||||
{showMore && (
|
||||
<div className="mt-8">
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
||||
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||||
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
|
||||
</div>
|
||||
<div className="field"><label>Category</label>
|
||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Color</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
||||
import { useCalendar, myParticipantKeys, type EventInstance } from "@/store/calendar";
|
||||
import { Popover, type Anchor } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
||||
import { describeRule } from "@/lib/recurrence";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { categoryOf, eventColor } from "./CalendarContextMenu";
|
||||
|
||||
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const ev = inst.event;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const categories = useSettings((s) => s.settings.eventCategories);
|
||||
const color = eventColor(ev, inst.calendar?.color, categories);
|
||||
const category = categoryOf(ev, categories);
|
||||
const participants = Object.entries(ev.participants ?? {});
|
||||
const myKeys = myParticipantKeys(ev, cal.identities);
|
||||
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
||||
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
||||
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
||||
const baseId = ev.baseEventId ?? ev.id;
|
||||
const location = Object.values(ev.locations ?? {})[0];
|
||||
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
||||
const alerts = Object.values(ev.alerts ?? {});
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const del = async () => {
|
||||
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
|
||||
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.destroyEvent(baseId, participants.length > 1);
|
||||
toast.success("Event deleted");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.rsvp(baseId, status);
|
||||
toast.success("Response sent");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
|
||||
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
|
||||
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
|
||||
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
|
||||
</div>
|
||||
<h3>{ev.title || "(untitled)"}</h3>
|
||||
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
|
||||
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</span></div>}
|
||||
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
|
||||
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
|
||||
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
|
||||
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
|
||||
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
|
||||
<div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div>
|
||||
{participants.length > 0 && (
|
||||
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
|
||||
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
|
||||
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
|
||||
{participants.map(([k, p]) => (
|
||||
<div key={k} className="participant-row">
|
||||
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
|
||||
<span className="truncate">{p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span>
|
||||
{p.roles?.owner && <span className="hint">organizer</span>}
|
||||
{p.roles?.optional && <span className="hint">optional</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{myKeys.length > 0 && !isOrganizer && (
|
||||
<div className="row" style={{ marginTop: 10, gap: 6 }}>
|
||||
<span className="hint">Going?</span>
|
||||
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
|
||||
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
|
||||
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { RecipientInput } from "./RecipientInput";
|
||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { formatSize, formatRelative } from "@/lib/format";
|
||||
import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { isValidEmail } from "@/lib/address";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function Composer({ draft }: { draft: Draft }) {
|
||||
const update = useCompose((s) => s.update);
|
||||
const close = useCompose((s) => s.close);
|
||||
const send = useCompose((s) => s.send);
|
||||
const saveDraft = useCompose((s) => s.saveDraft);
|
||||
const addFiles = useCompose((s) => s.addFiles);
|
||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
const focus = useCompose((s) => s.focus);
|
||||
const identities = useMail((s) => s.identities);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const isMobile = useIsMobile();
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const sendMenu = useMenu();
|
||||
const templateMenu = useMenu();
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const d = draft;
|
||||
const key = d.key;
|
||||
|
||||
const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]);
|
||||
const onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
|
||||
|
||||
// Esc closes (saves draft); Ctrl+Enter sends
|
||||
useEffect(() => {
|
||||
if (d.minimized) return;
|
||||
return keyboard.pushScope("composer", [
|
||||
{ keys: "mod+enter", description: "Send message", group: "Compose", handler: () => void doSend(), allowInInput: true },
|
||||
{ keys: "esc", description: "Close composer (saves draft)", group: "Compose", handler: () => { if (document.activeElement?.closest(".composer")) { void close(key); return true; } return false; }, allowInInput: true },
|
||||
{ keys: "mod+s", description: "Save draft", group: "Compose", handler: () => { void saveDraft(key); }, allowInInput: true },
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, d.minimized]);
|
||||
|
||||
const bodyText = useMemo(() => (d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text), [d.html, d.text, d.format]);
|
||||
|
||||
const doSend = async () => {
|
||||
const all = [...d.to, ...d.cc, ...d.bcc];
|
||||
if (!all.length) {
|
||||
toast.error("Please add at least one recipient");
|
||||
return;
|
||||
}
|
||||
const bad = all.filter((a) => !isValidEmail(a.email));
|
||||
if (bad.length) {
|
||||
toast.error(`Invalid address: ${bad[0]!.email}`);
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => a.error)) {
|
||||
toast.error("Remove attachments that failed to upload first");
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => !a.blobId)) {
|
||||
toast.error("Attachments are still uploading");
|
||||
return;
|
||||
}
|
||||
if (!d.subject.trim()) {
|
||||
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
|
||||
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
await send(key);
|
||||
};
|
||||
|
||||
const toggleFormat = () => {
|
||||
if (d.format === "html") {
|
||||
patch({ format: "text", text: htmlToText(d.html) });
|
||||
} else {
|
||||
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") });
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length) addFiles(key, files);
|
||||
};
|
||||
|
||||
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
||||
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
||||
const totalSize = d.attachments.reduce((n, a) => n + a.size, 0);
|
||||
|
||||
if (d.minimized) {
|
||||
return (
|
||||
<div className="composer minimized" onClick={() => focus(key)}>
|
||||
<div className="composer-head">
|
||||
<span className="title">{title}</span>
|
||||
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
|
||||
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
|
||||
<span className="title">{title}</span>
|
||||
<span className="status">{status}</span>
|
||||
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="composer-body">
|
||||
<div className="composer-fields">
|
||||
{identities.length > 1 && (
|
||||
<div className="composer-field">
|
||||
<label>From</label>
|
||||
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
|
||||
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-to`}>To</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} />
|
||||
<span className="field-extra">
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
</span>
|
||||
</div>
|
||||
{d.showReplyTo && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
|
||||
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
|
||||
</div>
|
||||
)}
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>Cc</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>Bcc</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
|
||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} />
|
||||
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
{d.format === "html" ? (
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} />
|
||||
) : (
|
||||
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
|
||||
)}
|
||||
{d.attachments.some((a) => !a.inline) && (
|
||||
<div className="composer-attachments">
|
||||
{d.attachments.filter((a) => !a.inline).map((a) => (
|
||||
<div key={a.id} className={`attachment ${a.error ? "error" : ""}`} title={a.error ?? a.name}>
|
||||
<span className="att-icon">{attachmentIcon(a.type, a.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{a.name}</span>
|
||||
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
|
||||
</span>
|
||||
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
|
||||
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-foot">
|
||||
<span className="send-group">
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
|
||||
</span>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
|
||||
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
</Popover>
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
|
||||
<MenuTitle>Templates</MenuTitle>
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||
</Popover>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
|
||||
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
|
||||
<MenuSep />
|
||||
<MenuTitle>Priority</MenuTitle>
|
||||
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Composer } from "./Composer";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
|
||||
export function ComposerDock() {
|
||||
const drafts = useCompose((s) => s.drafts);
|
||||
const activeKey = useCompose((s) => s.activeKey);
|
||||
const isMobile = useIsMobile();
|
||||
if (!drafts.length) return null;
|
||||
// On mobile only the active composer is shown (full screen); others are minimized bars.
|
||||
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
|
||||
return (
|
||||
<div className="composer-dock">
|
||||
{visible.map((d) => (
|
||||
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
||||
import { useContacts, type Suggestion } from "@/store/contacts";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
|
||||
interface Props {
|
||||
value: EmailAddress[];
|
||||
onChange: (v: EmailAddress[]) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggest = useContacts((s) => s.suggest);
|
||||
const reqId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const q = text.trim();
|
||||
if (!q) {
|
||||
setSugg([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const id = ++reqId.current;
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
setOpen(filtered.length > 0);
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
if (!s) return;
|
||||
const parsed = parseAddressList(s);
|
||||
if (!parsed.length) return;
|
||||
onChange([...value, ...parsed.filter((p) => !value.some((v) => v.email.toLowerCase() === p.email.toLowerCase()))]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const pick = (s: Suggestion) => {
|
||||
onChange([...value, { name: s.name, email: s.email }]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (open && sugg.length) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a + 1) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a - 1 + sugg.length) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (sugg[active]) {
|
||||
e.preventDefault();
|
||||
pick(sugg[active]!);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||||
if (text.trim()) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Enter") e.preventDefault();
|
||||
} else if (e.key === "Tab" && text.trim()) {
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && !text && value.length) {
|
||||
const last = value[value.length - 1]!;
|
||||
onChange(value.slice(0, -1));
|
||||
setText(last.name ? `${last.name} <${last.email}>` : last.email);
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const t = e.clipboardData.getData("text");
|
||||
if (t && /[,;\n]|<.+@.+>/.test(t)) {
|
||||
e.preventDefault();
|
||||
commit(text + t);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipients" onClick={() => inputRef.current?.focus()}>
|
||||
{value.map((a, i) => (
|
||||
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
||||
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
||||
<button type="button" className="chip-x" aria-label={`Remove ${a.email}`} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
placeholder={value.length ? "" : placeholder}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => { window.setTimeout(() => { setOpen(false); if (text.trim()) commit(); }, 150); }}
|
||||
onFocus={() => sugg.length && setOpen(true)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
/>
|
||||
{open && (
|
||||
<div className="suggest-list" role="listbox">
|
||||
{sugg.map((s, i) => (
|
||||
<div key={s.email} className={`suggest-item ${i === active ? "active" : ""}`} role="option" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(s); }} onMouseEnter={() => setActive(i)}>
|
||||
<Avatar who={s} size="sm" />
|
||||
<div className="col" style={{ minWidth: 0 }}>
|
||||
<span className="s-name truncate">{s.name ?? s.email}</span>
|
||||
{s.name && <span className="s-email truncate">{s.email}</span>}
|
||||
</div>
|
||||
<span className="s-src">{s.source === "gal" ? "Directory" : s.source === "recent" ? "Recent" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ClipboardEvent, type ReactNode } from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
|
||||
export interface RichEditorHandle {
|
||||
focus(): void;
|
||||
insertHtml(html: string): void;
|
||||
insertText(text: string): void;
|
||||
getHtml(): string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
spellcheck?: boolean;
|
||||
onFiles?: (files: File[]) => void;
|
||||
toolbarExtra?: ReactNode;
|
||||
showToolbar: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** If provided, inserted images are uploaded and referenced by URL instead of embedded as data: URLs. */
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEditor({ html, onChange, placeholder, spellcheck = true, onFiles, toolbarExtra, showToolbar, autoFocus, imageUpload }, ref) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const lastEmitted = useRef<string>("");
|
||||
const [empty, setEmpty] = useState(!html);
|
||||
const emojiMenu = useMenu();
|
||||
const colorMenu = useMenu();
|
||||
const hiliteMenu = useMenu();
|
||||
const linkMenu = useMenu();
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const savedRange = useRef<Range | null>(null);
|
||||
|
||||
// Sync external html → DOM (only when it differs from what we emitted)
|
||||
useEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
if (html !== lastEmitted.current) {
|
||||
el.innerHTML = html;
|
||||
lastEmitted.current = html;
|
||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// caret at start
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(el, 0);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const emit = useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
const v = el.innerHTML;
|
||||
lastEmitted.current = v;
|
||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||
onChange(v);
|
||||
}, [onChange]);
|
||||
|
||||
const exec = useCallback(
|
||||
(cmd: string, value?: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand(cmd, false, value);
|
||||
emit();
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
const saveRange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount && elRef.current?.contains(sel.anchorNode)) savedRange.current = sel.getRangeAt(0).cloneRange();
|
||||
};
|
||||
const restoreRange = () => {
|
||||
const r = savedRange.current;
|
||||
if (!r) return;
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(r);
|
||||
};
|
||||
|
||||
const insertHtml = useCallback(
|
||||
(h: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand("insertHTML", false, h);
|
||||
emit();
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => elRef.current?.focus(),
|
||||
insertHtml,
|
||||
insertText: (t: string) => {
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
document.execCommand("insertText", false, t);
|
||||
emit();
|
||||
},
|
||||
getHtml: () => elRef.current?.innerHTML ?? "",
|
||||
}));
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const imgItem = items.find((i) => i.type.startsWith("image/"));
|
||||
if (imgItem) {
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
const clean = sanitizeEditorHtml(htmlData).replace(/<meta[^>]*>/gi, "");
|
||||
document.execCommand("insertHTML", false, clean);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
// plain text: let browser handle (it inserts text nodes) but normalize newlines
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text && /\n/.test(text)) {
|
||||
e.preventDefault();
|
||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\r?\n/g, "<br>");
|
||||
document.execCommand("insertHTML", false, escaped);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
const insertImageFile = (f: File) => {
|
||||
if (imageUpload) {
|
||||
imageUpload(f)
|
||||
.then((url) => insertHtml(`<img src="${url}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`))
|
||||
.catch(() => {
|
||||
/* uploader reports its own errors */
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
insertHtml(`<img src="${reader.result as string}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
linkMenu.close();
|
||||
if (!url) return;
|
||||
const href = /^(https?:|mailto:|tel:)/i.test(url) ? url : `https://${url}`;
|
||||
elRef.current?.focus();
|
||||
restoreRange();
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.isCollapsed) document.execCommand("insertHTML", false, `<a href="${href}" target="_blank" rel="noopener">${href}</a>`);
|
||||
else document.execCommand("createLink", false, href);
|
||||
emit();
|
||||
setLinkUrl("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer-editor">
|
||||
<div
|
||||
ref={elRef}
|
||||
className="editor-area"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={spellcheck}
|
||||
data-placeholder={placeholder ?? ""}
|
||||
data-empty={empty}
|
||||
onInput={emit}
|
||||
onBlur={saveRange}
|
||||
onKeyUp={saveRange}
|
||||
onMouseUp={saveRange}
|
||||
onPaste={onPaste}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
saveRange();
|
||||
linkMenu.open(e.currentTarget);
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
exec(e.shiftKey ? "outdent" : "indent");
|
||||
}
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Message body"
|
||||
/>
|
||||
{showToolbar && (
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
||||
<option value="" disabled>Size</option>
|
||||
<option value="1">Small</option>
|
||||
<option value="3">Normal</option>
|
||||
<option value="5">Large</option>
|
||||
<option value="7">Huge</option>
|
||||
</select>
|
||||
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
||||
<span className="tb-sep" />
|
||||
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
|
||||
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
|
||||
<ImageIcon size={16} />
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
|
||||
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
)}
|
||||
<Popover anchor={emojiMenu.anchor} onClose={emojiMenu.close} side="top" closeOnClick={false} width={290}>
|
||||
<div className="emoji-grid">
|
||||
{EMOJI.map((e) => (
|
||||
<button key={e} type="button" onMouseDown={(ev) => ev.preventDefault()} onClick={() => { insertHtml(e); emojiMenu.close(); }}>{e}</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={colorMenu.anchor} onClose={colorMenu.close} side="top" closeOnClick={false} width={230}>
|
||||
<div className="color-grid">
|
||||
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("foreColor", c); colorMenu.close(); }} aria-label={c} />)}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={hiliteMenu.anchor} onClose={hiliteMenu.close} side="top" closeOnClick={false} width={230}>
|
||||
<div className="color-grid">
|
||||
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("hiliteColor", c); hiliteMenu.close(); }} aria-label={c} />)}
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
||||
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
||||
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
||||
<button type="submit" className="btn btn-sm btn-primary">Link</button>
|
||||
</form>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Camera, X } from "lucide-react";
|
||||
import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { buildName, contactDisplayName, nameParts, newKey } from "@/lib/contacts";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { client } from "@/jmap/client";
|
||||
|
||||
interface Props {
|
||||
card: Partial<ContactCard>;
|
||||
defaultBookId: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
}
|
||||
|
||||
const EMAIL_CTX = ["private", "work", "other"];
|
||||
const PHONE_CTX = ["mobile", "private", "work", "fax", "other"];
|
||||
const ADDR_CTX = ["private", "work", "other"];
|
||||
|
||||
type EmailRow = { key: string; address: string; ctx: string };
|
||||
type PhoneRow = { key: string; number: string; ctx: string };
|
||||
type AddrRow = { key: string; ctx: string; street: string; city: string; region: string; postcode: string; country: string };
|
||||
|
||||
export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) {
|
||||
const contacts = useContacts();
|
||||
const isNew = !card.id;
|
||||
const np = card.id ? nameParts(card as ContactCard) : { given: "", surname: "", middle: "", prefix: "", suffix: "" };
|
||||
const [kind, setKind] = useState<"individual" | "group" | "org">((card.kind as "individual" | "group" | "org") ?? "individual");
|
||||
const [given, setGiven] = useState(np.given);
|
||||
const [surname, setSurname] = useState(np.surname);
|
||||
const [prefix, setPrefix] = useState(np.prefix);
|
||||
const [middle, setMiddle] = useState(np.middle);
|
||||
const [suffix, setSuffix] = useState(np.suffix);
|
||||
const [nickname, setNickname] = useState(Object.values(card.nicknames ?? {})[0]?.name ?? "");
|
||||
const [company, setCompany] = useState(Object.values(card.organizations ?? {})[0]?.name ?? "");
|
||||
const [jobTitle, setJobTitle] = useState(Object.values(card.titles ?? {})[0]?.name ?? "");
|
||||
const [emails, setEmails] = useState<EmailRow[]>(() => Object.entries(card.emails ?? {}).map(([key, e]) => ({ key, address: e.address, ctx: Object.keys(e.contexts ?? {})[0] ?? "other" })));
|
||||
const [phones, setPhones] = useState<PhoneRow[]>(() => Object.entries(card.phones ?? {}).map(([key, p]) => ({ key, number: p.number, ctx: Object.keys(p.features ?? {})[0] ?? Object.keys(p.contexts ?? {})[0] ?? "other" })));
|
||||
const [addrs, setAddrs] = useState<AddrRow[]>(() => Object.entries(card.addresses ?? {}).map(([key, a]) => {
|
||||
const get = (k: string) => (a.components ?? []).filter((c) => c.kind === k).map((c) => c.value).join(" ");
|
||||
return { key, ctx: Object.keys(a.contexts ?? {})[0] ?? "other", street: [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ") || (a.full ?? ""), city: get("locality"), region: get("region"), postcode: get("postcode"), country: get("country") };
|
||||
}));
|
||||
const [birthday, setBirthday] = useState(() => {
|
||||
const b = Object.values(card.anniversaries ?? {}).find((a) => a.kind === "birth")?.date;
|
||||
return b?.year && b.month && b.day ? `${b.year}-${String(b.month).padStart(2, "0")}-${String(b.day).padStart(2, "0")}` : "";
|
||||
});
|
||||
const [website, setWebsite] = useState(Object.values(card.links ?? {})[0]?.uri ?? "");
|
||||
const [note, setNote] = useState(Object.values(card.notes ?? {})[0]?.note ?? "");
|
||||
const [bookId, setBookId] = useState(Object.keys(card.addressBookIds ?? {})[0] ?? defaultBookId ?? "");
|
||||
const [photo, setPhoto] = useState<{ dataUrl: string; type: string } | null>(null);
|
||||
const [removePhoto, setRemovePhoto] = useState(false);
|
||||
const [memberUids, setMemberUids] = useState<string[]>(Object.keys(card.members ?? {}));
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const books = Object.values(contacts.books);
|
||||
const existingPhoto = card.id && contacts.accountId ? Object.values(card.media ?? {}).find((m) => m.kind === "photo") : undefined;
|
||||
|
||||
const memberCandidates = useMemo(() => {
|
||||
if (!memberQuery.trim()) return [];
|
||||
return contacts.search(memberQuery).filter((c) => c.kind !== "group" && !memberUids.includes(c.uid)).slice(0, 6);
|
||||
}, [memberQuery, contacts, memberUids]);
|
||||
|
||||
const save = async () => {
|
||||
if (!bookId) {
|
||||
toast.error("Choose an address book");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj.kind = kind;
|
||||
const name = buildName({ given, surname, middle, prefix, suffix });
|
||||
if (kind === "individual") obj.name = name ?? null;
|
||||
else {
|
||||
obj.name = company ? { "@type": "Name", full: company } : (name ?? null);
|
||||
}
|
||||
obj.nicknames = nickname ? { [newKey("n")]: { "@type": "Nickname", name: nickname } } : null;
|
||||
obj.organizations = company ? { [newKey("o")]: { "@type": "Organization", name: company } } : null;
|
||||
obj.titles = jobTitle ? { [newKey("t")]: { "@type": "Title", name: jobTitle, kind: "title" } } : null;
|
||||
const em: Record<string, JSContactEmail> = {};
|
||||
emails.filter((e) => e.address.trim()).forEach((e, i) => { em[e.key] = { "@type": "EmailAddress", address: e.address.trim(), contexts: e.ctx !== "other" ? { [e.ctx]: true } : undefined, pref: i === 0 ? 1 : undefined }; });
|
||||
obj.emails = Object.keys(em).length ? em : null;
|
||||
const ph: Record<string, JSContactPhone> = {};
|
||||
phones.filter((p) => p.number.trim()).forEach((p) => { ph[p.key] = { "@type": "Phone", number: p.number.trim(), ...(["mobile", "fax"].includes(p.ctx) ? { features: { [p.ctx === "mobile" ? "mobile" : "fax"]: true } } : p.ctx !== "other" ? { contexts: { [p.ctx]: true } } : {}) }; });
|
||||
obj.phones = Object.keys(ph).length ? ph : null;
|
||||
const ad: Record<string, JSContactAddress> = {};
|
||||
addrs.filter((a) => a.street || a.city || a.country || a.postcode).forEach((a) => {
|
||||
const components: JSContactAddress["components"] = [];
|
||||
if (a.street) components.push({ "@type": "AddressComponent", kind: "name", value: a.street });
|
||||
if (a.city) components.push({ "@type": "AddressComponent", kind: "locality", value: a.city });
|
||||
if (a.region) components.push({ "@type": "AddressComponent", kind: "region", value: a.region });
|
||||
if (a.postcode) components.push({ "@type": "AddressComponent", kind: "postcode", value: a.postcode });
|
||||
if (a.country) components.push({ "@type": "AddressComponent", kind: "country", value: a.country });
|
||||
ad[a.key] = { "@type": "Address", components, contexts: a.ctx !== "other" ? { [a.ctx]: true } : undefined };
|
||||
});
|
||||
obj.addresses = Object.keys(ad).length ? ad : null;
|
||||
if (birthday) {
|
||||
const [y, m, d] = birthday.split("-").map(Number) as [number, number, number];
|
||||
obj.anniversaries = { [newKey("a")]: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", year: y, month: m, day: d } } };
|
||||
} else obj.anniversaries = null;
|
||||
obj.links = website ? { [newKey("l")]: { "@type": "Link", uri: /^https?:/i.test(website) ? website : `https://${website}` } } : null;
|
||||
obj.notes = note.trim() ? { [newKey("x")]: { "@type": "Note", note: note.trim() } } : null;
|
||||
obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null;
|
||||
if (photo) {
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(photo.dataUrl);
|
||||
if (m) {
|
||||
const bin = atob(m[2]!);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const up = await client.upload(contacts.accountId!, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
|
||||
obj.media = { [newKey("p")]: { "@type": "Media", kind: "photo", blobId: up.blobId, mediaType: m[1]! } };
|
||||
}
|
||||
} else if (removePhoto) obj.media = null;
|
||||
if (isNew) {
|
||||
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
||||
toast.success("Contact created");
|
||||
onSaved(id);
|
||||
} else {
|
||||
const patch: Record<string, unknown> = { ...obj };
|
||||
const curBook = Object.keys(card.addressBookIds ?? {})[0];
|
||||
if (curBook !== bookId) patch.addressBookIds = { [bookId]: true };
|
||||
if (!photo && !removePhoto) delete patch.media;
|
||||
await contacts.updateCard(card.id!, patch);
|
||||
toast.success("Contact saved");
|
||||
onSaved(card.id!);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPhoto = (f: File) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(f);
|
||||
img.onload = () => {
|
||||
const size = 256;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext("2d")!;
|
||||
const s = Math.min(img.width, img.height);
|
||||
ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);
|
||||
setPhoto({ dataUrl: c.toDataURL("image/jpeg", 0.85), type: "image/jpeg" });
|
||||
setRemovePhoto(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={isNew ? "New contact" : `Edit ${contactDisplayName(card as ContactCard)}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
||||
<div className="contact-form">
|
||||
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
|
||||
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title="Change photo">
|
||||
{photoSrc ? <img src={photoSrc} alt="" /> : <Camera size={28} />}
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
|
||||
<span className="spacer" />
|
||||
<div className="field" style={{ marginBottom: 0, width: 160 }}>
|
||||
<label>Type</label>
|
||||
<select className="select" value={kind} onChange={(e) => setKind(e.target.value as typeof kind)}>
|
||||
<option value="individual">Person</option>
|
||||
<option value="org">Organization</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0, width: 200 }}>
|
||||
<label>Address book</label>
|
||||
<select className="select" value={bookId} onChange={(e) => setBookId(e.target.value)}>
|
||||
{books.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{kind === "individual" ? (
|
||||
<>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>First name</label><input className="input" value={given} onChange={(e) => setGiven(e.target.value)} autoFocus /></div>
|
||||
<div className="field"><label>Last name</label><input className="input" value={surname} onChange={(e) => setSurname(e.target.value)} /></div>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="hint" style={{ cursor: "pointer", marginBottom: 8 }}>More name fields</summary>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Prefix</label><input className="input" value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder="Dr." /></div>
|
||||
<div className="field"><label>Middle name</label><input className="input" value={middle} onChange={(e) => setMiddle(e.target.value)} /></div>
|
||||
<div className="field"><label>Suffix</label><input className="input" value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder="Jr." /></div>
|
||||
<div className="field"><label>Nickname</label><input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} /></div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Company</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} /></div>
|
||||
<div className="field"><label>Job title</label><input className="input" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} /></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="field"><label>{kind === "group" ? "Group name" : "Organization name"}</label><input className="input" value={company} onChange={(e) => setCompany(e.target.value)} autoFocus /></div>
|
||||
)}
|
||||
|
||||
{kind === "group" && (
|
||||
<div className="field">
|
||||
<label>Members</label>
|
||||
<div className="row wrap gap-4 mb-8">
|
||||
{memberUids.map((uid) => {
|
||||
const m = Object.values(contacts.cards).find((x) => x.uid === uid);
|
||||
return <span key={uid} className="chip">{m ? contactDisplayName(m) : uid}<button className="chip-x" onClick={() => setMemberUids(memberUids.filter((u) => u !== uid))}><X size={12} /></button></span>;
|
||||
})}
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input className="input" placeholder="Search contacts to add…" value={memberQuery} onChange={(e) => setMemberQuery(e.target.value)} />
|
||||
{memberCandidates.length > 0 && (
|
||||
<div className="suggest-list" style={{ width: "100%" }}>
|
||||
{memberCandidates.map((c) => <div key={c.id} className="suggest-item" onMouseDown={(e) => { e.preventDefault(); setMemberUids([...memberUids, c.uid]); setMemberQuery(""); }}><span className="s-name">{contactDisplayName(c)}</span><span className="s-email">{Object.values(c.emails ?? {})[0]?.address}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Email</label>
|
||||
<div className="multi">
|
||||
{emails.map((e, i) => (
|
||||
<div key={e.key} className="multi-row">
|
||||
<input className="input" type="email" value={e.address} placeholder="[email protected]" onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, address: ev.target.value } : x)))} />
|
||||
<select className="select" value={e.ctx} onChange={(ev) => setEmails(emails.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{EMAIL_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setEmails(emails.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setEmails([...emails, { key: newKey("e"), address: "", ctx: emails.length ? "work" : "private" }])}><Plus size={14} /> Add email</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Phone</label>
|
||||
<div className="multi">
|
||||
{phones.map((p, i) => (
|
||||
<div key={p.key} className="multi-row">
|
||||
<input className="input" type="tel" value={p.number} placeholder="+1 555 0100" onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, number: ev.target.value } : x)))} />
|
||||
<select className="select" value={p.ctx} onChange={(ev) => setPhones(phones.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{PHONE_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<button className="icon-btn sm danger" onClick={() => setPhones(phones.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setPhones([...phones, { key: newKey("p"), number: "", ctx: "mobile" }])}><Plus size={14} /> Add phone</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Address</label>
|
||||
<div className="multi">
|
||||
{addrs.map((a, i) => (
|
||||
<div key={a.key} className="card" style={{ marginBottom: 0 }}>
|
||||
<div className="row mb-8">
|
||||
<select className="select" style={{ width: 140 }} value={a.ctx} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, ctx: ev.target.value } : x)))}>{ADDR_CTX.map((c) => <option key={c} value={c}>{c}</option>)}</select>
|
||||
<span className="spacer" />
|
||||
<button className="icon-btn sm danger" onClick={() => setAddrs(addrs.filter((_, j) => j !== i))} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div className="addr-grid">
|
||||
<input className="input" style={{ gridColumn: "1 / -1" }} placeholder="Street" value={a.street} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, street: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="City" value={a.city} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="State / Region" value={a.region} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, region: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="Postal code" value={a.postcode} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, postcode: ev.target.value } : x)))} />
|
||||
<input className="input" placeholder="Country" value={a.country} onChange={(ev) => setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: ev.target.value } : x)))} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAddrs([...addrs, { key: newKey("a"), ctx: "private", street: "", city: "", region: "", postcode: "", country: "" }])}><Plus size={14} /> Add address</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Birthday</label><input className="input" type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} /></div>
|
||||
<div className="field"><label>Website</label><input className="input" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://" /></div>
|
||||
</div>
|
||||
<div className="field"><label>Notes</label><textarea className="textarea" value={note} onChange={(e) => setNote(e.target.value)} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ArrowLeft, Book, Download, Mail, MoreVertical, Pencil, Plus, Search, Share2, Trash2, Upload, Users, Phone, MapPin, Building2, Cake, StickyNote, Globe, Calendar as CalIcon, Star, Pin } from "lucide-react";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { AddressBook, ContactCard } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
||||
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { avatarColor } from "@/lib/address";
|
||||
|
||||
export function ContactsView({ id }: { id?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const contacts = useContacts();
|
||||
const narrow = useIsNarrow();
|
||||
const [q, setQ] = useState("");
|
||||
const [bookId, setBookId] = useState<string | "all">("all");
|
||||
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
|
||||
const [share, setShare] = useState<AddressBook | null>(null);
|
||||
const bookMenu = useMenu();
|
||||
const [menuBook, setMenuBook] = useState<AddressBook | null>(null);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
useEffect(() => {
|
||||
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contacts.available, contacts.loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const onNew = () => setEditing({});
|
||||
window.addEventListener("ihm:new-contact", onNew);
|
||||
return () => window.removeEventListener("ihm:new-contact", onNew);
|
||||
}, []);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const all = contacts.search(q);
|
||||
return bookId === "all" ? all : all.filter((c) => c.addressBookIds?.[bookId]);
|
||||
}, [contacts, q, bookId]);
|
||||
|
||||
const selected = id ? contacts.cards[id] : undefined;
|
||||
const books = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ letter: string; items: ContactCard[] }> = [];
|
||||
for (const c of list) {
|
||||
const letter = (sortKey(c)[0] ?? "#").toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : "#";
|
||||
const g = out[out.length - 1];
|
||||
if (g && g.letter === key) g.items.push(c);
|
||||
else out.push({ letter: key, items: [c] });
|
||||
}
|
||||
return out;
|
||||
}, [list]);
|
||||
|
||||
if (!contacts.available) {
|
||||
return <div className="p-16"><Empty icon={<Users size={40} />} title="Contacts are not available">This account does not have the JMAP contacts capability.</Empty></div>;
|
||||
}
|
||||
|
||||
const exportAll = () => {
|
||||
const text = list.map(toVCard).join("");
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: "text/vcard" }));
|
||||
a.download = "contacts.vcf";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const importFile = async (f: File) => {
|
||||
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]);
|
||||
if (!book) {
|
||||
toast.error("Create an address book first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = await contacts.importVCard(await f.text(), book.id);
|
||||
toast.success(`Imported ${n} contact${n === 1 ? "" : "s"}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
||||
<aside className="contacts-books">
|
||||
<button className={`nav-item ${bookId === "all" ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId("all")}>
|
||||
<Users size={18} /><span className="nav-label">All contacts</span><span className="nav-count">{Object.keys(contacts.cards).length}</span>
|
||||
</button>
|
||||
<div className="nav-section"><span>Address books</span>
|
||||
<button className="icon-btn" title="New address book" onClick={async () => { const n = await promptDialog({ title: "New address book", placeholder: "Name" }); if (n?.trim()) { try { await contacts.createBook(n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><Plus size={16} /></button>
|
||||
</div>
|
||||
{books.map((b) => (
|
||||
<button key={b.id} className={`nav-item ${bookId === b.id ? "active" : ""}`} style={{ width: "100%" }} onClick={() => setBookId(b.id)} onContextMenu={(e) => { e.preventDefault(); setMenuBook(b); bookMenu.openAt(e.clientX, e.clientY); }}>
|
||||
<Book size={18} /><span className="nav-label">{b.name}</span>
|
||||
<span className="icon-btn nav-more" onClick={(e) => { e.stopPropagation(); setMenuBook(b); bookMenu.open(e); }}><MoreVertical size={16} /></span>
|
||||
</button>
|
||||
))}
|
||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||
<label className="btn btn-sm btn-block"><Upload size={14} /> Import vCard<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
|
||||
<button className="btn btn-sm btn-block" onClick={exportAll}><Download size={14} /> Export {bookId === "all" ? "all" : "book"}</button>
|
||||
</div>
|
||||
<Popover anchor={bookMenu.anchor} onClose={bookMenu.close} width={220}>
|
||||
{menuBook && (
|
||||
<>
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name }); if (n?.trim()) void contacts.updateBook(menuBook.id, { name: n.trim() }).catch((err) => toast.error((err as Error).message)); }} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuBook)} />
|
||||
<MenuItem icon={<Star size={16} />} label={menuBook.isDefault ? "Default book" : "Make default"} disabled={menuBook.isDefault} onClick={() => void contacts.updateBook(menuBook.id, { isDefault: true } as Partial<AddressBook>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuBook.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "All contacts in it will be deleted.", confirmLabel: "Delete", danger: true })) void contacts.destroyBook(menuBook.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</aside>
|
||||
|
||||
<section className="contacts-list">
|
||||
<div className="list-search row">
|
||||
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={16} className="muted" />
|
||||
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder="Search contacts" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<button className="icon-btn" title="New contact" onClick={() => setEditing({})}><Plus size={20} /></button>
|
||||
</div>
|
||||
<div className="contacts-scroll">
|
||||
{contacts.loading && !contacts.loaded ? <Spinner label="Loading contacts…" /> : !list.length ? (
|
||||
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
|
||||
) : groups.map((g) => (
|
||||
<div key={g.letter}>
|
||||
<div className="contact-letter">{g.letter}</div>
|
||||
{g.items.map((c) => {
|
||||
const email = contactEmails(c)[0]?.email;
|
||||
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
||||
return (
|
||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="c-name">{contactDisplayName(c)}{c.kind === "group" ? <span className="hint"> · group</span> : ""}</div>
|
||||
<div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="contact-detail">
|
||||
{selected ? (
|
||||
<ContactDetail card={selected} onBack={() => navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} />
|
||||
) : (
|
||||
<div className="no-thread"><Users size={48} style={{ color: "var(--fg-faint)" }} /><div>Select a contact</div></div>
|
||||
)}
|
||||
</section>
|
||||
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
|
||||
{share && <ShareDialog kind="AddressBook" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) {
|
||||
const contacts = useContacts();
|
||||
const [, navigate] = useLocation();
|
||||
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
||||
const name = contactDisplayName(c);
|
||||
const org = Object.values(c.organizations ?? {})[0];
|
||||
const title = Object.values(c.titles ?? {})[0];
|
||||
const books = Object.keys(c.addressBookIds ?? {}).map((id) => contacts.books[id]?.name).filter(Boolean);
|
||||
const members = c.kind === "group" ? Object.keys(c.members ?? {}).map((uid) => Object.values(contacts.cards).find((x) => x.uid === uid)).filter((x): x is ContactCard => Boolean(x)) : [];
|
||||
const ctxLabel = (ctx?: Record<string, boolean>, label?: string) => label || Object.keys(ctx ?? {}).join(", ") || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
{narrow && <button className="icon-btn" onClick={onBack} aria-label="Back"><ArrowLeft size={20} /></button>}
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> Edit</button>
|
||||
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> vCard</button>
|
||||
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: `Delete ${name}?`, confirmLabel: "Delete", danger: true })) { try { await contacts.destroyCards([c.id]); toast.success("Contact deleted"); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<div className="contact-hero">
|
||||
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
|
||||
<div>
|
||||
<h1>{name}</h1>
|
||||
{(title?.name || org?.name) && <div className="sub">{[title?.name, org?.name].filter(Boolean).join(" · ")}</div>}
|
||||
{Object.values(c.nicknames ?? {})[0]?.name && <div className="sub">“{Object.values(c.nicknames ?? {})[0]!.name}”</div>}
|
||||
{books.length > 0 && <div className="hint">{books.join(", ")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{Object.values(c.emails ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Email</h3>
|
||||
{Object.values(c.emails ?? {}).map((e, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel(e.contexts, e.label) || "email"}</span><span className="v row gap-8"><a href={`mailto:${e.address}`} onClick={(ev) => { ev.preventDefault(); onEmail(e.address); }}>{e.address}</a><button className="icon-btn xs" title="Compose" onClick={() => onEmail(e.address)}><Mail size={14} /></button></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.phones ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Phone</h3>
|
||||
{Object.values(c.phones ?? {}).map((p, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel({ ...p.contexts, ...p.features }, p.label) || "phone"}</span><span className="v row gap-8"><Phone size={14} className="muted" /><a href={`tel:${p.number}`}>{p.number}</a></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.addresses ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Address</h3>
|
||||
{Object.values(c.addresses ?? {}).map((a, i) => (
|
||||
<div key={i} className="contact-kv"><span className="k">{ctxLabel(a.contexts) || "address"}</span><span className="v row gap-8" style={{ alignItems: "flex-start" }}><MapPin size={14} className="muted" style={{ marginTop: 3 }} /><span>{formatAddressLines(a).map((l, j) => <div key={j}>{l}</div>)}</span></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(org || Object.values(c.titles ?? {}).length > 1) && (
|
||||
<div className="contact-section"><h3>Work</h3>
|
||||
{org?.name && <div className="contact-kv"><span className="k">Company</span><span className="v row gap-8"><Building2 size={14} className="muted" />{org.name}{org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}</span></div>}
|
||||
{Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.anniversaries ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Dates</h3>
|
||||
{Object.values(c.anniversaries ?? {}).map((a, i) => <div key={i} className="contact-kv"><span className="k">{a.kind === "birth" ? "Birthday" : a.kind === "wedding" ? "Anniversary" : a.kind}</span><span className="v row gap-8"><Cake size={14} className="muted" />{fmtPartial(a.date)}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (
|
||||
<div className="contact-section"><h3>Online</h3>
|
||||
{Object.values(c.links ?? {}).map((l, i) => <div key={`l${i}`} className="contact-kv"><span className="k">{l.label ?? "Website"}</span><span className="v row gap-8"><Globe size={14} className="muted" /><a href={l.uri} target="_blank" rel="noreferrer">{l.uri}</a></span></div>)}
|
||||
{Object.values(c.onlineServices ?? {}).map((s, i) => <div key={`s${i}`} className="contact-kv"><span className="k">{s.service ?? s.label ?? "IM"}</span><span className="v">{s.user ?? s.uri}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{Object.values(c.notes ?? {}).length > 0 && (
|
||||
<div className="contact-section"><h3>Notes</h3>
|
||||
{Object.values(c.notes ?? {}).map((n, i) => <div key={i} className="contact-kv"><span className="k"><StickyNote size={14} /></span><span className="v" style={{ whiteSpace: "pre-wrap" }}>{n.note}</span></div>)}
|
||||
</div>
|
||||
)}
|
||||
{c.kind === "group" && (
|
||||
<div className="contact-section"><h3>Members ({Object.keys(c.members ?? {}).length})</h3>
|
||||
{members.map((m) => <div key={m.id} className="contact-kv"><span className="k"><Avatar who={{ name: contactDisplayName(m), email: contactEmails(m)[0]?.email }} size="sm" /></span><span className="v"><a href={`/contacts/${m.id}`} onClick={(e) => { e.preventDefault(); navigate(`/contacts/${m.id}`); }}>{contactDisplayName(m)}</a> <span className="hint">{contactEmails(m)[0]?.email}</span></span></div>)}
|
||||
{members.length > 0 && <button className="btn btn-sm mt-8" onClick={() => useCompose.getState().open({ to: members.flatMap((m) => contactEmails(m).slice(0, 1)) })}><Mail size={14} /> Email group</button>}
|
||||
</div>
|
||||
)}
|
||||
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
|
||||
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {new Date(c.updated).toLocaleDateString()}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string {
|
||||
if (d.utc) return new Date(d.utc).toLocaleDateString();
|
||||
if (d.year && d.month && d.day) return new Date(d.year, d.month - 1, d.day).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
if (d.month && d.day) return new Date(2000, d.month - 1, d.day).toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
||||
return [d.year, d.month, d.day].filter(Boolean).join("-");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const files = useFiles();
|
||||
const parentId = nodeId ?? null;
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const menu = useMenu();
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.available) void files.loadChildren(parentId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files.available, parentId]);
|
||||
|
||||
// Ensure ancestors are loaded for breadcrumbs
|
||||
useEffect(() => {
|
||||
if (!files.available || !parentId) return;
|
||||
const n = files.nodes[parentId];
|
||||
if (!n) {
|
||||
void client.call<{ list: FileNode[] }>("FileNode/get", { accountId: files.accountId, ids: [parentId], fetchParents: true }).then((r) => {
|
||||
useFiles.setState((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const x of r.list) nodes[x.id] = x;
|
||||
return { nodes };
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentId, files.available]);
|
||||
|
||||
if (!files.available) return <div className="p-16"><Empty icon={<FolderOpen size={40} />} title="File storage is not available">This account does not have the JMAP file storage capability.</Empty></div>;
|
||||
|
||||
const ids = files.children[parentId ?? "root"] ?? [];
|
||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(parentId);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const list = Array.from(e.dataTransfer.files);
|
||||
if (list.length) void files.upload(parentId, list);
|
||||
};
|
||||
|
||||
const download = (n: FileNode) => {
|
||||
if (!n.blobId) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = client.downloadUrl(files.accountId!, n.blobId, n.name, n.type ?? "application/octet-stream");
|
||||
a.download = n.name;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||
<div className="files-toolbar">
|
||||
<div className="breadcrumb">
|
||||
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
||||
{path.map((n, i) => (
|
||||
<span key={n.id} className="row gap-4">
|
||||
<ChevronRight size={14} className="faint" />
|
||||
<button className={i === path.length - 1 ? "current" : ""} onClick={() => navigate(`/files/${n.id}`)}>{n.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => inputRef.current?.click()}><Upload size={16} /> Upload</button>
|
||||
<input ref={inputRef} type="file" multiple hidden onChange={(e) => { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
|
||||
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> New folder</button>
|
||||
</div>
|
||||
{files.uploads.length > 0 && (
|
||||
<div className="list-hint" style={{ flexDirection: "column", alignItems: "stretch", gap: 4 }}>
|
||||
{files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||
<div className="files-scroll">
|
||||
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
|
||||
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
|
||||
) : (
|
||||
<table className="files-table">
|
||||
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span></div></td>
|
||||
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) {
|
||||
const files = useFiles();
|
||||
const [cur, setCur] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void files.loadChildren(cur);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur]);
|
||||
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
|
||||
const path = files.pathTo(cur);
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Move “${node.name}”`} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>Move here</button></>}>
|
||||
<div className="breadcrumb mb-8">
|
||||
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
||||
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
||||
</div>
|
||||
{dirs.map((d) => <button key={d.id} className="menu-item" onClick={() => setCur(d.id)}><Folder size={16} /><span className="grow">{d.name}</span><ChevronRight size={14} /></button>)}
|
||||
{!dirs.length && <p className="hint">No subfolders here.</p>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply";
|
||||
import type { SieveRule } from "@/lib/sieve";
|
||||
import { RuleDialog } from "../settings/RuleDialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
|
||||
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
|
||||
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
|
||||
const sieve = useSieve();
|
||||
const mailbox = useMail((s) => (mailboxId ? s.mailboxes[mailboxId] : undefined));
|
||||
const [rule] = useState<SieveRule>(() => ruleFromEmail(email, mailboxId));
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (sieve.available && !sieve.scripts.length && !sieve.loading) await sieve.load();
|
||||
setReady(true);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!sieve.available) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<p>Sieve filtering is not enabled for this account.</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
|
||||
|
||||
const { rules } = sieve.rules();
|
||||
if (rules === null) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
|
||||
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings → Filters & rules</b> to edit the script or switch to managed rules.</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleDialog
|
||||
rule={rule}
|
||||
title="Filter messages like this"
|
||||
saveLabel="Create filter"
|
||||
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
|
||||
onClose={onClose}
|
||||
onSave={(r, applyNow) => {
|
||||
onClose();
|
||||
void saveAndApply(r, rules, applyNow && mailbox ? mailbox.id : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
|
||||
const sieve = useSieve.getState();
|
||||
try {
|
||||
await sieve.saveRules([...existing.filter((x) => x.id !== r.id), r]);
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filter: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!applyMailboxId) {
|
||||
toast.success("Filter created — it will run on new mail");
|
||||
return;
|
||||
}
|
||||
const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
|
||||
try {
|
||||
const res = await applyRuleToMailbox(r, applyMailboxId);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Filter created · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
|
||||
} catch (err) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
|
||||
import { useCalendar, toInstance, myParticipantKeys } from "@/store/calendar";
|
||||
import { formatTimeRange } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
|
||||
const cal = useCalendar();
|
||||
const [, navigate] = useLocation();
|
||||
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||
const [existing, setExisting] = useState<CalendarEvent | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cal.available || !part.blobId) return;
|
||||
let cancelled = false;
|
||||
cal
|
||||
.parseIcs(part.blobId)
|
||||
.then(async (evs) => {
|
||||
if (cancelled) return;
|
||||
setEvents(evs);
|
||||
const first = evs[0];
|
||||
if (first?.uid) setExisting(await cal.findByUid(first.uid));
|
||||
})
|
||||
.catch((err) => !cancelled && setError((err as Error).message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [part.blobId, cal.available]);
|
||||
|
||||
if (!cal.available) return null;
|
||||
if (error) return null;
|
||||
const ev = events?.[0];
|
||||
if (!ev) return null;
|
||||
const method = (ev.method ?? "").toUpperCase();
|
||||
const inst = toInstance({ ...ev, id: "tmp", calendarIds: {} } as CalendarEvent, cal.calendars);
|
||||
const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner);
|
||||
const location = Object.values(ev.locations ?? {})[0]?.name;
|
||||
const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null;
|
||||
const attendees = Object.values(ev.participants ?? {}).filter((p) => p.roles?.attendee);
|
||||
|
||||
const respond = async (status: "accepted" | "tentative" | "declined") => {
|
||||
setBusy(status);
|
||||
try {
|
||||
let target = existing;
|
||||
if (!target) {
|
||||
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
||||
if (!calId) throw new Error("No calendar available");
|
||||
const id = await cal.importEvent(ev, calId);
|
||||
target = await cal.getEvent(id);
|
||||
}
|
||||
if (!target) throw new Error("Could not add the event to your calendar");
|
||||
await cal.rsvp(target.id, status);
|
||||
setExisting(await cal.getEvent(target.id));
|
||||
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addToCalendar = async () => {
|
||||
setBusy("add");
|
||||
try {
|
||||
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
|
||||
if (!calId) throw new Error("No calendar available");
|
||||
const id = await cal.importEvent(ev, calId);
|
||||
setExisting(await cal.getEvent(id));
|
||||
toast.success("Added to your calendar");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const title = method === "CANCEL" ? "Cancelled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
|
||||
|
||||
return (
|
||||
<div className="invite-card">
|
||||
<div className="row" style={{ alignItems: "flex-start" }}>
|
||||
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
|
||||
<div className="grow">
|
||||
<div className="hint" style={{ marginBottom: 2 }}>{title}{method === "REPLY" && organizer ? "" : ""}</div>
|
||||
<h4>{ev.title || "(untitled event)"}</h4>
|
||||
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
|
||||
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
|
||||
{organizer && <div className="small muted">Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</div>}
|
||||
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
|
||||
{method === "REPLY" && (
|
||||
<div className="small" style={{ marginTop: 4 }}>
|
||||
{attendees.map((a) => <div key={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{method !== "REPLY" && method !== "CANCEL" && (
|
||||
<div className="rsvp">
|
||||
{(method === "REQUEST" || attendees.length > 0) ? (
|
||||
<>
|
||||
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("accepted")}><Check size={14} /> {myStatus === "accepted" ? "Accepted" : "Yes"}</button>
|
||||
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("tentative")}><HelpCircle size={14} /> {myStatus === "tentative" ? "Tentative" : "Maybe"}</button>
|
||||
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("declined")}><X size={14} /> {myStatus === "declined" ? "Declined" : "No"}</button>
|
||||
</>
|
||||
) : (
|
||||
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
|
||||
)}
|
||||
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
|
||||
</div>
|
||||
)}
|
||||
{method === "CANCEL" && existing && (
|
||||
<div className="rsvp">
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{email.id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Popover } from "@/ui/popover";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
|
||||
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
|
||||
export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; anchor: { x: number; y: number }; onClose: () => void; onApplied?: () => void }) {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const update = useSettings((s) => s.update);
|
||||
const emails = useMail((s) => s.emails);
|
||||
const setKeyword = useMail((s) => s.setKeyword);
|
||||
const [q, setQ] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const has = (kw: string) => ids.every((id) => emails[id]?.keywords[kw]);
|
||||
const some = (kw: string) => ids.some((id) => emails[id]?.keywords[kw]);
|
||||
const filtered = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
const create = () => {
|
||||
const name = q.trim();
|
||||
if (!name) return;
|
||||
const keyword = name.toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
|
||||
if (labels.some((l) => l.keyword === keyword)) return;
|
||||
const color = CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]!;
|
||||
update({ labels: [...labels, { keyword, name, color }] });
|
||||
void setKeyword(ids, keyword, true).then(onApplied);
|
||||
setQ("");
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
|
||||
<div className="menu-title">Label as</div>
|
||||
<div className="menu-search">
|
||||
<input
|
||||
className="input sm"
|
||||
autoFocus
|
||||
placeholder={labels.length ? "Search or create label" : "New label name"}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered.length === 1 && !creating) {
|
||||
const l = filtered[0]!;
|
||||
void setKeyword(ids, l.keyword, !has(l.keyword)).then(onApplied);
|
||||
} else create();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((l) => {
|
||||
const all = has(l.keyword);
|
||||
const partial = !all && some(l.keyword);
|
||||
return (
|
||||
<label key={l.keyword} className="menu-item" style={{ cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={all}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = partial;
|
||||
}}
|
||||
onChange={(e) => void setKeyword(ids, l.keyword, e.target.checked).then(onApplied)}
|
||||
style={{ accentColor: l.color }}
|
||||
/>
|
||||
<span className="label-dot" style={{ background: l.color }} />
|
||||
<span className="grow truncate">{l.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && (
|
||||
<button className="menu-item" onClick={create}>
|
||||
<Plus size={16} />
|
||||
<span>Create “{q.trim()}”</span>
|
||||
</button>
|
||||
)}
|
||||
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>Type a name to create your first label.</div>}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsNarrow } from "@/ui/misc";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { ThreadView } from "./ThreadView";
|
||||
import { MailboxPicker } from "./MailboxPicker";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||
const [, navigate] = useLocation();
|
||||
const searchStr = useSearch();
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxesLoaded = useMail((s) => s.mailboxesLoaded);
|
||||
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||
const query = useMail((s) => s.query);
|
||||
const list = useMail((s) => s.list);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const narrow = useIsNarrow();
|
||||
const [focusId, setFocusId] = useState<Id | null>(null);
|
||||
const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null);
|
||||
const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null);
|
||||
|
||||
const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]);
|
||||
|
||||
// Redirect /mail → inbox
|
||||
useEffect(() => {
|
||||
if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [search, mailboxId, inboxId, navigate]);
|
||||
|
||||
// Build & run the list query
|
||||
const listQuery = useMemo<ListQuery | null>(() => {
|
||||
if (search) {
|
||||
if (!q) return null;
|
||||
const parsed = parseQuery(q);
|
||||
const filter = buildFilter(parsed, mailboxes, null);
|
||||
const inMb = parsed.in ? (Object.values(mailboxes).find((m) => m.name.toLowerCase() === parsed.in!.toLowerCase())?.id ?? null) : null;
|
||||
return { key: "", filter, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode, mailboxId: inMb, label: describeFilter(parsed) };
|
||||
}
|
||||
if (!mailboxId) return null;
|
||||
const mb = mailboxes[mailboxId];
|
||||
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent";
|
||||
return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
|
||||
}, [search, q, mailboxId, mailboxes, settings.conversationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listQuery && mailboxesLoaded) void query(listQuery);
|
||||
}, [listQuery, query, mailboxesLoaded]);
|
||||
|
||||
const openThread = useCallback(
|
||||
(tid: Id | null) => {
|
||||
const base = search ? `/search` : `/mail/${mailboxId}`;
|
||||
const qs = search ? `?q=${encodeURIComponent(q)}` : "";
|
||||
navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`);
|
||||
},
|
||||
[navigate, search, mailboxId, q],
|
||||
);
|
||||
|
||||
// Row ids in list + helpers for keyboard nav
|
||||
const ids = list?.ids ?? [];
|
||||
const emails = useMail((s) => s.emails);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const selected = useMail((s) => s.selected);
|
||||
|
||||
const rowThreadId = useCallback((rowId: Id) => emails[rowId]?.threadId, [emails]);
|
||||
const currentRowIndex = useMemo(() => {
|
||||
if (focusId) {
|
||||
const i = ids.indexOf(focusId);
|
||||
if (i >= 0) return i;
|
||||
}
|
||||
if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId);
|
||||
return -1;
|
||||
}, [ids, focusId, threadId, rowThreadId]);
|
||||
|
||||
/** Email ids affected by an action on rows (selection or focused/open row). */
|
||||
const targetIds = useCallback(
|
||||
(rowIds?: Id[]): Id[] => {
|
||||
const rows = rowIds ?? (Object.keys(selected).length ? Object.keys(selected) : focusId ? [focusId] : threadId ? ids.filter((id) => rowThreadId(id) === threadId) : []);
|
||||
const out = new Set<Id>();
|
||||
for (const r of rows) {
|
||||
const e = emails[r];
|
||||
if (!e) continue;
|
||||
if (list?.collapseThreads) {
|
||||
const t = threads[e.threadId];
|
||||
const inScope = t ? t.emailIds.filter((id) => (list.mailboxId ? emails[id]?.mailboxIds[list.mailboxId] : true)) : [r];
|
||||
for (const id of inScope.length ? inScope : [r]) out.add(id);
|
||||
} else out.add(r);
|
||||
}
|
||||
return [...out];
|
||||
},
|
||||
[selected, focusId, threadId, ids, rowThreadId, emails, threads, list],
|
||||
);
|
||||
|
||||
const afterAction = useCallback(
|
||||
(removed: boolean) => {
|
||||
useMail.getState().clearSelection();
|
||||
if (!removed) return;
|
||||
// auto-advance
|
||||
if (threadId) {
|
||||
const idx = currentRowIndex;
|
||||
const adv = settings.autoAdvance;
|
||||
if (adv === "list" || idx < 0) openThread(null);
|
||||
else {
|
||||
const next = adv === "older" ? ids[idx + 1] : ids[idx - 1];
|
||||
const nt = next ? rowThreadId(next) : undefined;
|
||||
if (nt) openThread(nt);
|
||||
else openThread(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread],
|
||||
);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
archive: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
await useMail.getState().archive(t);
|
||||
afterAction(true);
|
||||
},
|
||||
trash: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
const mail = useMail.getState();
|
||||
const trashId = mail.roleId("trash");
|
||||
const permanent = t.every((id) => trashId && mail.emails[id]?.mailboxIds[trashId]);
|
||||
if (permanent || settings.confirmDelete) {
|
||||
const ok = await confirmDialog({ title: permanent ? "Delete forever?" : "Delete?", message: permanent ? `${t.length} message(s) will be permanently deleted.` : `Move ${t.length} message(s) to Trash?`, confirmLabel: "Delete", danger: permanent });
|
||||
if (!ok) return;
|
||||
}
|
||||
await mail.trash(t);
|
||||
afterAction(true);
|
||||
},
|
||||
spam: async (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (!t.length) return;
|
||||
const mail = useMail.getState();
|
||||
const junk = mail.roleId("junk");
|
||||
const inJunk = t.every((id) => junk && mail.emails[id]?.mailboxIds[junk]);
|
||||
await mail.spam(t, !inJunk);
|
||||
afterAction(true);
|
||||
},
|
||||
read: async (read: boolean, rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) await useMail.getState().markRead(t, read);
|
||||
useMail.getState().clearSelection();
|
||||
},
|
||||
star: async (on: boolean, rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) await useMail.getState().star(t, on);
|
||||
},
|
||||
move: (rows?: Id[]) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) setMovePicker({ ids: t });
|
||||
},
|
||||
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => {
|
||||
const t = targetIds(rows);
|
||||
if (t.length) setLabelPicker({ ids: t, anchor });
|
||||
},
|
||||
moveTo: async (ids: Id[], mailboxId: Id) => {
|
||||
await useMail.getState().move(ids, mailboxId);
|
||||
afterAction(true);
|
||||
},
|
||||
}),
|
||||
[targetIds, afterAction, settings.confirmDelete],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts for the list/thread
|
||||
const focusRef = useRef(focusId);
|
||||
focusRef.current = focusId;
|
||||
useEffect(() => {
|
||||
const moveFocus = (delta: number) => {
|
||||
const cur = focusRef.current ? ids.indexOf(focusRef.current) : currentRowIndex;
|
||||
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
|
||||
const id = ids[next];
|
||||
if (!id) return;
|
||||
setFocusId(id);
|
||||
if (threadId && settings.readingPane !== "off") {
|
||||
const t = rowThreadId(id);
|
||||
if (t) openThread(t);
|
||||
}
|
||||
document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
return keyboard.pushScope("mail", [
|
||||
{ keys: "j", description: "Next conversation", group: "Mail", handler: () => moveFocus(1) },
|
||||
{ keys: "k", description: "Previous conversation", group: "Mail", handler: () => moveFocus(-1) },
|
||||
{ keys: "arrowdown", description: "", group: "Mail", handler: () => moveFocus(1) },
|
||||
{ keys: "arrowup", description: "", group: "Mail", handler: () => moveFocus(-1) },
|
||||
{ keys: "o", description: "Open conversation", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) openThread(t); } },
|
||||
{ keys: "enter", description: "", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) { openThread(t); return; } return false; } },
|
||||
{ keys: "u", description: "Back to list", group: "Mail", handler: () => openThread(null) },
|
||||
{ keys: "esc", description: "Back to list / clear selection", group: "Mail", handler: () => { if (Object.keys(useMail.getState().selected).length) useMail.getState().clearSelection(); else openThread(null); } },
|
||||
{ keys: "x", description: "Select conversation", group: "Mail", handler: () => { const id = focusRef.current ?? ids[currentRowIndex]; if (id) useMail.getState().select([id], !useMail.getState().selected[id]); } },
|
||||
{ keys: "e", description: "Archive", group: "Actions", handler: () => void actions.archive() },
|
||||
{ keys: "y", description: "", group: "Actions", handler: () => void actions.archive() },
|
||||
{ keys: "#", description: "Delete", group: "Actions", handler: () => void actions.trash() },
|
||||
{ keys: "delete", description: "", group: "Actions", handler: () => void actions.trash() },
|
||||
{ keys: "!", description: "Report spam / not spam", group: "Actions", handler: () => void actions.spam() },
|
||||
{ keys: "s", description: "Star / unstar", group: "Actions", handler: () => { const t = targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); } },
|
||||
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: () => void actions.read(true) },
|
||||
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: () => void actions.read(false) },
|
||||
{ keys: "v", description: "Move to…", group: "Actions", handler: () => actions.move() },
|
||||
{ keys: "l", description: "Label…", group: "Actions", handler: () => actions.label(undefined, { x: window.innerWidth / 2, y: 80 }) },
|
||||
{ keys: "*+a", description: "", group: "Actions", handler: () => useMail.getState().selectAll() },
|
||||
{ keys: "mod+a", description: "Select all", group: "Mail", handler: () => { useMail.getState().selectAll(); } },
|
||||
{ keys: "r", description: "Reply", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "reply" })) },
|
||||
{ keys: "a", description: "Reply all", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "replyAll" })) },
|
||||
{ keys: "f", description: "Forward", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "forward" })) },
|
||||
{ keys: "n", description: "Next message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: 1 })) },
|
||||
{ keys: "p", description: "Previous message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: -1 })) },
|
||||
{ keys: "]", description: "Archive and next", group: "Conversation", handler: () => void actions.archive() },
|
||||
]);
|
||||
}, [ids, currentRowIndex, threadId, settings.readingPane, rowThreadId, openThread, actions, targetIds, emails]);
|
||||
|
||||
const openDraft = useCompose((s) => s.openDraftEmail);
|
||||
const onOpenRow = useCallback(
|
||||
(rowId: Id) => {
|
||||
const e = emails[rowId];
|
||||
if (!e) return;
|
||||
setFocusId(rowId);
|
||||
const mb = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||
if (mb?.role === "drafts" && e.keywords.$draft) {
|
||||
void openDraft(e);
|
||||
return;
|
||||
}
|
||||
openThread(e.threadId);
|
||||
},
|
||||
[emails, mailboxId, mailboxes, openThread, openDraft],
|
||||
);
|
||||
|
||||
const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
|
||||
const reading = Boolean(threadId);
|
||||
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
||||
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
||||
const showReading = settings.readingPane !== "off" || reading;
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const [liveSize, setLiveSize] = useState<number | null>(null);
|
||||
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
|
||||
const onSplit = (delta: number) => {
|
||||
const el = layoutRef.current;
|
||||
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
|
||||
const min = settings.readingPane === "bottom" ? 160 : 320;
|
||||
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
|
||||
setLiveSize((cur) => Math.min(max, Math.max(min, (cur ?? paneSize) + delta)));
|
||||
};
|
||||
const onSplitEnd = () => {
|
||||
if (liveSize == null) return;
|
||||
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize });
|
||||
setLiveSize(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={layoutRef} className={`mail-layout ${paneClass} ${reading ? "reading" : ""}`} style={{ "--list-size": `${paneSize}px` } as React.CSSProperties}>
|
||||
{showList && (
|
||||
<MessageList
|
||||
title={title}
|
||||
list={list}
|
||||
openThreadId={threadId ?? null}
|
||||
focusId={focusId}
|
||||
setFocusId={setFocusId}
|
||||
onOpen={onOpenRow}
|
||||
actions={actions}
|
||||
mailboxId={mailboxId ?? null}
|
||||
isSearch={Boolean(search)}
|
||||
/>
|
||||
)}
|
||||
{showList && showReading && settings.readingPane !== "off" && !narrow && (
|
||||
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel="Resize message list" />
|
||||
)}
|
||||
{showReading && (
|
||||
<div className="mail-reading-pane">
|
||||
{threadId ? (
|
||||
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
|
||||
) : (
|
||||
<div className="no-thread">
|
||||
<img src="/img/logo.png" alt="" />
|
||||
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
|
||||
<div className="hint">Select a conversation to read it here · Press <kbd className="kbd">?</kbd> for shortcuts</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{movePicker && (
|
||||
<MailboxPicker
|
||||
title={`Move ${movePicker.ids.length} message${movePicker.ids.length === 1 ? "" : "s"} to…`}
|
||||
onClose={() => setMovePicker(null)}
|
||||
onPick={(mbId) => {
|
||||
setMovePicker(null);
|
||||
void actions.moveTo(movePicker.ids, mbId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{labelPicker && (
|
||||
<LabelPicker
|
||||
ids={labelPicker.ids}
|
||||
anchor={labelPicker.anchor}
|
||||
onClose={() => setLabelPicker(null)}
|
||||
onApplied={() => toast.show("Labels updated")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Folder, Inbox } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
|
||||
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const [q, setQ] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const list = useMemo(() => {
|
||||
const all = Object.values(mailboxes)
|
||||
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
|
||||
.map((m) => ({ m, path: mailboxPath(m.id) }))
|
||||
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
||||
const ql = q.trim().toLowerCase();
|
||||
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
||||
}, [mailboxes, mailboxPath, q, exclude]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={title} size="sm">
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
placeholder="Type a folder name…"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(list.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const m = list[active]?.m;
|
||||
if (m) onPick(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
|
||||
{list.map(({ m, path }, i) => (
|
||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
|
||||
))}
|
||||
{!list.length && <div className="empty" style={{ padding: 24 }}>No matching folders</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({ m, path, active, onClick, onHover }: { m: Mailbox; path: string; active: boolean; onClick: () => void; onHover: () => void }) {
|
||||
return (
|
||||
<button className={`menu-item ${active ? "active" : ""}`} onClick={onClick} onMouseEnter={onHover} role="option" aria-selected={active}>
|
||||
{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
|
||||
<span className="grow truncate">{path}</span>
|
||||
<span className="menu-kbd">{m.totalEmails}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
inbox: <Inbox size={20} />,
|
||||
drafts: <File size={20} />,
|
||||
sent: <Send size={20} />,
|
||||
trash: <Trash2 size={20} />,
|
||||
junk: <AlertOctagon size={20} />,
|
||||
archive: <Archive size={20} />,
|
||||
all: <Mail size={20} />,
|
||||
flagged: <Star size={20} />,
|
||||
important: <Tag size={20} />,
|
||||
};
|
||||
|
||||
export function MailboxTree() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const loaded = useMail((s) => s.mailboxesLoaded);
|
||||
const [location] = useLocation();
|
||||
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
|
||||
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
|
||||
const menu = useMenu();
|
||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||||
|
||||
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
||||
// collapsed by default. Expansion state is remembered per folder.
|
||||
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
|
||||
const toggle = (id: Id) => {
|
||||
const next = { ...expanded, [id]: !expanded[id] };
|
||||
setExpanded(next);
|
||||
saveJson("mbx-expanded", next);
|
||||
};
|
||||
const rows = useMemo(() => {
|
||||
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
|
||||
const byParent = new Map<Id | null, Mailbox[]>();
|
||||
for (const m of all) {
|
||||
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
|
||||
byParent.set(p, [...(byParent.get(p) ?? []), m]);
|
||||
}
|
||||
const cmp = (a: Mailbox, b: Mailbox) => {
|
||||
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
||||
};
|
||||
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
|
||||
const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0);
|
||||
const walk = (parent: Id | null, depth: number) => {
|
||||
for (const m of (byParent.get(parent) ?? []).sort(cmp)) {
|
||||
const kids = byParent.get(m.id) ?? [];
|
||||
const open = Boolean(expanded[m.id]);
|
||||
const childUnread = kids.length ? subtreeUnread(m.id) : 0;
|
||||
out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread });
|
||||
if (kids.length && open) walk(m.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(null, 0);
|
||||
return out;
|
||||
}, [mailboxes, showHidden, expanded]);
|
||||
|
||||
const createFolder = async (parentId: Id | null) => {
|
||||
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useMail.getState().createMailbox(name.trim(), parentId);
|
||||
toast.success(`Folder “${name.trim()}” created`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div style={{ padding: "8px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="skeleton" style={{ height: 28, width: `${70 + (i % 3) * 10}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||||
<div className="nav-section">
|
||||
<span>Folders</span>
|
||||
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
|
||||
<FolderRow key={m.id} mailbox={m} label={m.name} depth={depth} hasChildren={hasChildren} open={open} hiddenUnread={hiddenUnread} childUnread={childUnread} onToggle={() => toggle(m.id)} currentId={currentId} onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} />
|
||||
))}
|
||||
{labelsSidebar && labels.length > 0 && (
|
||||
<>
|
||||
<div className="nav-section">
|
||||
<span>Labels</span>
|
||||
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
|
||||
<Pencil size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
{labels.map((l) => (
|
||||
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item" title={l.name}>
|
||||
<span className="nav-label-color" style={{ background: l.color }} />
|
||||
<span className="nav-label">{l.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={240}>
|
||||
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
|
||||
</Popover>
|
||||
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) {
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const own = m.role === "drafts" ? m.totalEmails : m.unreadEmails;
|
||||
const count = own + hiddenUnread;
|
||||
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
|
||||
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts";
|
||||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : <Folder size={20} />;
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!dropping) setDropping(true);
|
||||
};
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
||||
if (!raw) return;
|
||||
try {
|
||||
const ids = JSON.parse(raw) as string[];
|
||||
void useMail.getState().move(ids, m.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/mail/${m.id}`}
|
||||
className={`nav-item depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
|
||||
title={label}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={onDrop}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onMenu(m, { currentTarget: e.currentTarget });
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
className="nav-twisty"
|
||||
role="button"
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
) : (
|
||||
depth > 0 && <span style={{ width: 4 }} />
|
||||
)}
|
||||
{icon}
|
||||
<span className="nav-label">{label}</span>
|
||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
<button
|
||||
className="icon-btn nav-more"
|
||||
aria-label="Folder options"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(m, e);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
|
||||
const [, navigate] = useLocation();
|
||||
const rename = async () => {
|
||||
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
|
||||
if (!name?.trim() || name.trim() === m.name) return;
|
||||
try {
|
||||
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const remove = async () => {
|
||||
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await useMail.getState().destroyMailbox(m.id, true);
|
||||
toast.success("Folder deleted");
|
||||
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const empty = async () => {
|
||||
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
|
||||
if (ok) await useMail.getState().emptyMailbox(m.id);
|
||||
};
|
||||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
|
||||
<MenuSep />
|
||||
{(m.role === "trash" || m.role === "junk") && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMail, type ListState } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
|
||||
export interface ListActions {
|
||||
archive: (rows?: Id[]) => Promise<void>;
|
||||
trash: (rows?: Id[]) => Promise<void>;
|
||||
spam: (rows?: Id[]) => Promise<void>;
|
||||
read: (read: boolean, rows?: Id[]) => Promise<void>;
|
||||
star: (on: boolean, rows?: Id[]) => Promise<void>;
|
||||
move: (rows?: Id[]) => void;
|
||||
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => void;
|
||||
moveTo: (ids: Id[], mailboxId: Id) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
list: ListState | null;
|
||||
openThreadId: Id | null;
|
||||
focusId: Id | null;
|
||||
setFocusId: (id: Id | null) => void;
|
||||
onOpen: (rowId: Id) => void;
|
||||
actions: ListActions;
|
||||
mailboxId: Id | null;
|
||||
isSearch: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
|
||||
const [, navigate] = useLocation();
|
||||
const emails = useMail((s) => s.emails);
|
||||
const threads = useMail((s) => s.threads);
|
||||
const selected = useMail((s) => s.selected);
|
||||
const select = useMail((s) => s.select);
|
||||
const selectAll = useMail((s) => s.selectAll);
|
||||
const clearSelection = useMail((s) => s.clearSelection);
|
||||
const loadMore = useMail((s) => s.loadMore);
|
||||
const refreshList = useMail((s) => s.refreshList);
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [paneWidth, setPaneWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setPaneWidth(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
|
||||
const ctxMenu = useMenu();
|
||||
const [ctxRow, setCtxRow] = useState<Id | null>(null);
|
||||
const moreMenu = useMenu();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filterFrom, setFilterFrom] = useState<Email | null>(null);
|
||||
const lastClick = useRef<Id | null>(null);
|
||||
|
||||
const ids = list?.ids ?? [];
|
||||
const selCount = Object.keys(selected).length;
|
||||
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
|
||||
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
|
||||
const isDrafts = mailbox?.role === "drafts";
|
||||
|
||||
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: ids.length + (list && !list.exhausted ? 1 : 0),
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
// Re-measure when the row height changes (one-line ↔ two-line, density).
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowHeight]);
|
||||
|
||||
// Infinite scroll
|
||||
const items = virtualizer.getVirtualItems();
|
||||
useEffect(() => {
|
||||
const last = items[items.length - 1];
|
||||
if (!last || !list) return;
|
||||
if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore();
|
||||
}, [items, ids.length, list, loadMore]);
|
||||
|
||||
// Pull-to-refresh-ish: manual refresh button
|
||||
const doRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refreshList();
|
||||
await useMail.getState().loadMailboxes();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(e: MouseEvent, rowId: Id) => {
|
||||
if (e.shiftKey && lastClick.current) {
|
||||
const a = ids.indexOf(lastClick.current);
|
||||
const b = ids.indexOf(rowId);
|
||||
if (a >= 0 && b >= 0) {
|
||||
const [s, en] = a < b ? [a, b] : [b, a];
|
||||
select(ids.slice(s, en + 1), true);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
select([rowId], !selected[rowId]);
|
||||
lastClick.current = rowId;
|
||||
return;
|
||||
}
|
||||
lastClick.current = rowId;
|
||||
if (selCount > 0 && isMobile) {
|
||||
select([rowId], !selected[rowId]);
|
||||
return;
|
||||
}
|
||||
onOpen(rowId);
|
||||
},
|
||||
[ids, select, selected, selCount, isMobile, onOpen],
|
||||
);
|
||||
|
||||
const onContext = useCallback(
|
||||
(e: MouseEvent, rowId: Id) => {
|
||||
e.preventDefault();
|
||||
setCtxRow(rowId);
|
||||
setFocusId(rowId);
|
||||
ctxMenu.openAt(e.clientX, e.clientY);
|
||||
},
|
||||
[ctxMenu, setFocusId],
|
||||
);
|
||||
|
||||
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||
|
||||
return (
|
||||
<div className="mail-list-pane">
|
||||
<div className="list-toolbar">
|
||||
{isMobile && isSearch && (
|
||||
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label="Back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="select-all"
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selCount > 0 && !allSelected;
|
||||
}}
|
||||
onChange={() => (allSelected || selCount > 0 ? clearSelection() : selectAll())}
|
||||
/>
|
||||
{selCount > 0 ? (
|
||||
<>
|
||||
<span className="tb-count">{selCount} selected</span>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive()}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title="Mark as read (Shift+I)" onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
||||
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="tb-title">{title}</span>
|
||||
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
|
||||
<span className="spacer" />
|
||||
<button className={`icon-btn ${refreshing ? "active" : ""}`} title="Refresh" onClick={() => void doRefresh()} aria-label="Refresh">
|
||||
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuTitle>Reading pane</MenuTitle>
|
||||
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
|
||||
<MenuItem icon={<PanelBottom size={16} />} label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
|
||||
<MenuItem icon={<PanelTop size={16} />} label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
|
||||
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
|
||||
{isTrashOrJunk && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Eraser size={16} />}
|
||||
label={`Empty ${mailbox?.name}`}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{list?.error && (
|
||||
<div className="list-hint">
|
||||
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
|
||||
<button onClick={() => void doRefresh()}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
|
||||
{list?.loading && ids.length === 0 ? (
|
||||
<div style={{ padding: 8 }}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}>
|
||||
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} />
|
||||
<span className="skeleton" style={{ width: 140, height: 14 }} />
|
||||
<span className="skeleton grow" style={{ height: 14 }} />
|
||||
<span className="skeleton" style={{ width: 50, height: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : ids.length === 0 && list && !list.loading ? (
|
||||
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
|
||||
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{items.map((vi) => {
|
||||
const id = ids[vi.index];
|
||||
if (!id) {
|
||||
return (
|
||||
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
|
||||
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const e = emails[id];
|
||||
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
|
||||
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
|
||||
return (
|
||||
<Row
|
||||
key={id}
|
||||
email={e}
|
||||
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
|
||||
top={vi.start}
|
||||
height={vi.size}
|
||||
selected={Boolean(selected[id])}
|
||||
focused={focusId === id}
|
||||
open={openThreadId === e.threadId}
|
||||
twoLine={twoLine}
|
||||
showAvatar={settings.showAvatars}
|
||||
showPreview={settings.showPreview}
|
||||
isDrafts={isDrafts}
|
||||
mailboxId={mailboxId}
|
||||
isSent={mailbox?.role === "sent"}
|
||||
onClick={onRowClick}
|
||||
onContext={onContext}
|
||||
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
|
||||
onStar={(rowId, on) => void actions.star(on, [rowId])}
|
||||
onArchive={(rowId) => void actions.archive([rowId])}
|
||||
onTrash={(rowId) => void actions.trash([rowId])}
|
||||
onRead={(rowId, read) => void actions.read(read, [rowId])}
|
||||
selectedIds={selected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
|
||||
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
||||
</Popover>
|
||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
email: Email;
|
||||
threadEmails?: Email[];
|
||||
top: number;
|
||||
height: number;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
open: boolean;
|
||||
twoLine: boolean;
|
||||
showAvatar: boolean;
|
||||
showPreview: boolean;
|
||||
isDrafts: boolean;
|
||||
isSent: boolean;
|
||||
mailboxId: Id | null;
|
||||
selectedIds: Record<Id, true>;
|
||||
onClick: (e: MouseEvent, id: Id) => void;
|
||||
onContext: (e: MouseEvent, id: Id) => void;
|
||||
onSelect: (id: Id, on: boolean) => void;
|
||||
onStar: (id: Id, on: boolean) => void;
|
||||
onArchive: (id: Id) => void;
|
||||
onTrash: (id: Id) => void;
|
||||
onRead: (id: Id, read: boolean) => void;
|
||||
}
|
||||
|
||||
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
||||
const scope = inScope.length ? inScope : [e];
|
||||
const unread = scope.some((x) => !x.keywords.$seen);
|
||||
const starred = scope.some((x) => x.keywords.$flagged);
|
||||
const hasAtt = scope.some((x) => x.hasAttachment);
|
||||
const answered = e.keywords.$answered;
|
||||
const forwarded = e.keywords.$forwarded;
|
||||
const latest = scope.reduce((a, b) => (a.receivedAt > b.receivedAt ? a : b), scope[0]!);
|
||||
const count = threadEmails ? scope.length : 0;
|
||||
// Participants: Gmail-style "Ann, Bob, Me (3)"
|
||||
const names = useMemo(() => {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const src = isSent || isDrafts ? scope.flatMap((x) => x.to ?? []) : scope.map((x) => x.from?.[0]).filter(Boolean);
|
||||
for (const a of src) {
|
||||
if (!a) continue;
|
||||
const k = a.email.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(count > 1 ? shortName(a) : displayName(a));
|
||||
}
|
||||
return out;
|
||||
}, [scope, isSent, isDrafts, count]);
|
||||
const who = (isSent || isDrafts ? (names.length ? `To: ${names.join(", ")}` : "(no recipients)") : names.join(", ")) || "(unknown)";
|
||||
const rowLabels = labels.filter((l) => scope.some((x) => x.keywords[l.keyword]));
|
||||
|
||||
const onDragStart = (ev: DragEvent) => {
|
||||
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
|
||||
// include thread emails in scope
|
||||
const all = new Set<Id>();
|
||||
for (const id of ids) {
|
||||
all.add(id);
|
||||
}
|
||||
for (const x of scope) all.add(x.id);
|
||||
ev.dataTransfer.setData("application/x-ihasmail-emails", JSON.stringify([...all]));
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
const ghost = document.createElement("div");
|
||||
ghost.className = "drag-ghost";
|
||||
ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`;
|
||||
document.body.appendChild(ghost);
|
||||
ev.dataTransfer.setDragImage(ghost, 10, 10);
|
||||
setTimeout(() => ghost.remove(), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""}`}
|
||||
style={{ top, height }}
|
||||
data-row-id={e.id}
|
||||
onClick={(ev) => onClick(ev, e.id)}
|
||||
onContextMenu={(ev) => onContext(ev, e.id)}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
role="row"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" />
|
||||
{!twoLine && (
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
|
||||
<Star size={18} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
)}
|
||||
{showAvatar && <Avatar who={isSent || isDrafts ? (e.to?.[0] ?? null) : (latest.from?.[0] ?? null)} />}
|
||||
{twoLine ? (
|
||||
<div className="msg-body">
|
||||
<div className="msg-line1">
|
||||
<span className="msg-from truncate">
|
||||
{who}
|
||||
{count > 1 && <span className="thread-count"> {count}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)" }}>Draft</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label="Star">
|
||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="msg-from" title={who}>
|
||||
<span className="truncate">{who}</span>
|
||||
{count > 1 && <span className="thread-count">{count}</span>}
|
||||
</span>
|
||||
<span className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>Draft</span>}
|
||||
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
{(answered || forwarded) && <span className="msg-answered" title={answered ? "Replied" : "Forwarded"}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
<span className="msg-actions">
|
||||
<button className="icon-btn sm" title="Archive" onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
||||
<button className="icon-btn sm" title="Delete" onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, sanitizeEmailHtml } from "@/lib/html";
|
||||
import { findQuoteStart, textToHtml } from "@/lib/text";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { ListActions } from "./MessageList";
|
||||
import { InviteCard } from "./InviteCard";
|
||||
import { VCardCard } from "./VCardCard";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
isLast: boolean;
|
||||
actions: ListActions;
|
||||
}
|
||||
|
||||
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) {
|
||||
const accountId = useMail((s) => s.accountId)!;
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const [details, setDetails] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showHeaders, setShowHeaders] = useState(false);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const from = e.from?.[0];
|
||||
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
|
||||
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
|
||||
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
|
||||
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
|
||||
|
||||
const htmlPart = e.htmlBody?.[0];
|
||||
const textPart = e.textBody?.[0];
|
||||
const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
|
||||
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
|
||||
const showHtml = Boolean(htmlRaw);
|
||||
|
||||
// Inline images map
|
||||
const cidMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const a of e.attachments ?? []) if (a.cid && a.blobId) map[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
|
||||
const walk = (p?: EmailBodyPart) => {
|
||||
if (!p) return;
|
||||
if (p.cid && p.blobId && !map[p.cid]) map[p.cid] = client.downloadUrl(accountId, p.blobId, p.name ?? "image", p.type, true);
|
||||
p.subParts?.forEach(walk);
|
||||
};
|
||||
walk(e.bodyStructure);
|
||||
return map;
|
||||
}, [e.attachments, e.bodyStructure, accountId]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!expanded) return null;
|
||||
if (showHtml) return sanitizeEmailHtml(htmlRaw!, { cidMap, allowRemote: remoteAllowed, proxyRemote: imageProxy });
|
||||
return null;
|
||||
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]);
|
||||
|
||||
const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]);
|
||||
const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]);
|
||||
const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]);
|
||||
const unsubscribe = e["header:List-Unsubscribe:asText"];
|
||||
const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? "");
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
|
||||
const openSource = async () => {
|
||||
setShowSource(true);
|
||||
if (source === null) {
|
||||
try {
|
||||
setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822"));
|
||||
} catch (err) {
|
||||
setSource(`Could not load source: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadEml = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822");
|
||||
a.download = "";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
if (!unsubscribe) return;
|
||||
const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
|
||||
const mailto = urls.find((u) => u.startsWith("mailto:"));
|
||||
const http = urls.find((u) => /^https?:/i.test(u));
|
||||
if (mailto) {
|
||||
const [addr, qs] = mailto.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
useCompose.getState().open({ to: [{ name: null, email: addr ?? "" }], subject: q.get("subject") ?? "unsubscribe", html: `<div>${q.get("body") ?? "unsubscribe"}</div>`, text: q.get("body") ?? "unsubscribe" });
|
||||
toast.show("Unsubscribe message prepared — just hit Send");
|
||||
} else if (http) {
|
||||
window.open(http, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const collapsedClick = () => {
|
||||
if (!expanded) onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
|
||||
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
|
||||
<Avatar who={from ?? null} />
|
||||
<div className="who">
|
||||
<div className="from">
|
||||
<span>{displayName(from)}</span>
|
||||
{expanded && from && <span className="email"><{from.email}></span>}
|
||||
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
|
||||
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="to">
|
||||
<span className="truncate">to {summarizeRecipients(e)}</span>
|
||||
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
|
||||
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="snippet">{e.preview}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="meta">
|
||||
{e.hasAttachment && !expanded && <Paperclip size={14} />}
|
||||
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
|
||||
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
|
||||
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
|
||||
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
|
||||
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
|
||||
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
|
||||
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
|
||||
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
|
||||
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
|
||||
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
|
||||
{from && (
|
||||
<>
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{details && (
|
||||
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
|
||||
<dt>From</dt><dd>{(e.from ?? []).map(formatAddress).join(", ")}</dd>
|
||||
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd>{e.sender.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.replyTo?.length ? <><dt>Reply-To</dt><dd>{e.replyTo.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>To</dt><dd>{(e.to ?? []).map(formatAddress).join(", ") || "—"}</dd>
|
||||
{e.cc?.length ? <><dt>Cc</dt><dd>{e.cc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
{e.bcc?.length ? <><dt>Bcc</dt><dd>{e.bcc.map(formatAddress).join(", ")}</dd></> : null}
|
||||
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
|
||||
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ImageIcon size={16} />
|
||||
<span className="grow">Remote images are blocked to protect your privacy.</span>
|
||||
<button onClick={() => setAllowRemote(true)}>Show images</button>
|
||||
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
|
||||
</div>
|
||||
)}
|
||||
{icsPart && <InviteCard email={e} part={icsPart} />}
|
||||
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
|
||||
<div className="message-body">
|
||||
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
|
||||
</div>
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
|
||||
{unsubscribe && (
|
||||
<div className="unsubscribe-row">
|
||||
<span>This looks like a mailing list.</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
|
||||
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
|
||||
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
|
||||
</Dialog>
|
||||
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
|
||||
<dl className="message-details" style={{ margin: 0 }}>
|
||||
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
|
||||
<>
|
||||
<dt key={`${k}-t`}>{k.split(":")[1]}</dt>
|
||||
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
|
||||
</>
|
||||
))}
|
||||
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
|
||||
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
|
||||
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
|
||||
</dl>
|
||||
<p className="hint">Use “Show original” for the complete raw message.</p>
|
||||
</Dialog>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
function summarizeRecipients(e: Email): string {
|
||||
const all = [...(e.to ?? []), ...(e.cc ?? [])];
|
||||
if (!all.length) return "(undisclosed recipients)";
|
||||
const me = useMail.getState().identities.map((i) => i.email.toLowerCase());
|
||||
const names = all.map((a) => (me.includes(a.email.toLowerCase()) ? "me" : displayName(a).split(" ")[0] || a.email));
|
||||
if (names.length <= 3) return names.join(", ");
|
||||
return `${names.slice(0, 3).join(", ")} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => boolean): EmailBodyPart | null {
|
||||
if (!p) return null;
|
||||
if (pred(p)) return p;
|
||||
for (const s of p.subParts ?? []) {
|
||||
const r = findPart(s, pred);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Body renderers ---------- */
|
||||
|
||||
const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"];
|
||||
|
||||
function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle: string; onShowImages: () => void }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [hasQuote, setHasQuote] = useState(false);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const onClick = useCallback(
|
||||
(ev: Event) => {
|
||||
const t = ev.target as HTMLElement;
|
||||
const a = t.closest("a");
|
||||
if (a) {
|
||||
const href = a.getAttribute("href") ?? "";
|
||||
if (href.startsWith("mailto:")) {
|
||||
ev.preventDefault();
|
||||
const [addr, qs] = href.slice(7).split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
openCompose({ to: addr ? addr.split(",").map((x) => ({ name: null, email: decodeURIComponent(x.trim()) })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
||||
return;
|
||||
}
|
||||
if (/^(javascript|data|vbscript):/i.test(href)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
const img = t.closest("img[data-ihm-blocked]");
|
||||
if (img) onShowImages();
|
||||
},
|
||||
[openCompose, onShowImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
|
||||
// Collapse quoted content
|
||||
const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
|
||||
let found = false;
|
||||
if (container) {
|
||||
let q: Element | null = null;
|
||||
for (const sel of QUOTE_SELECTORS) {
|
||||
q = container.querySelector(sel);
|
||||
if (q) break;
|
||||
}
|
||||
if (!q) {
|
||||
// Heuristic: a blockquote preceded by text ending in "wrote:"
|
||||
const bqs = Array.from(container.querySelectorAll("blockquote"));
|
||||
for (const bq of bqs) {
|
||||
const prev = bq.previousElementSibling;
|
||||
if (prev && /wrote:\s*$|Original Message|Von:|De :|From:/i.test(prev.textContent ?? "")) {
|
||||
q = prev;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!q && bqs.length === 1 && (bqs[0]!.textContent?.length ?? 0) > 200) q = bqs[0]!;
|
||||
}
|
||||
if (q && q.parentElement) {
|
||||
// Move q and subsequent siblings into a hidden wrapper (only if q isn't the whole body)
|
||||
const parent = q.parentElement;
|
||||
const textBefore = (container.textContent ?? "").indexOf((q.textContent ?? "").slice(0, 40));
|
||||
if (textBefore > 0 || q.previousElementSibling) {
|
||||
const wrap = root.ownerDocument.createElement("div");
|
||||
wrap.className = "ihm-quoted";
|
||||
wrap.hidden = true;
|
||||
const nodes: ChildNode[] = [];
|
||||
let n: ChildNode | null = q.classList.contains("moz-cite-prefix") ? q : q;
|
||||
while (n) {
|
||||
nodes.push(n);
|
||||
n = n.nextSibling;
|
||||
}
|
||||
parent.insertBefore(wrap, q);
|
||||
for (const node of nodes) wrap.appendChild(node);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
setHasQuote(found);
|
||||
setQuoteOpen(false);
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
}, [html, bodyStyle, onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = hostRef.current?.shadowRoot;
|
||||
const q = root?.querySelector<HTMLElement>(".ihm-quoted");
|
||||
if (q) q.hidden = !quoteOpen;
|
||||
}, [quoteOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={hostRef} className="body-host" />
|
||||
{hasQuote && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBody({ text }: { text: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
const { main, quoted } = useMemo(() => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const idx = findQuoteStart(lines);
|
||||
if (idx > 2) return { main: lines.slice(0, idx).join("\n"), quoted: lines.slice(idx).join("\n") };
|
||||
return { main: text, quoted: "" };
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
|
||||
const onClick = (ev: Event) => {
|
||||
const a = (ev.target as HTMLElement).closest("a");
|
||||
if (a && a.getAttribute("href")?.startsWith("mailto:")) {
|
||||
ev.preventDefault();
|
||||
openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] });
|
||||
}
|
||||
};
|
||||
root.addEventListener("click", onClick);
|
||||
return () => root.removeEventListener("click", onClick);
|
||||
}, [main, quoted, quoteOpen, openCompose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={hostRef} className="body-host" />
|
||||
{quoted && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Attachments ---------- */
|
||||
|
||||
export function attachmentIcon(type: string, name?: string | null) {
|
||||
const t = type.toLowerCase();
|
||||
const n = (name ?? "").toLowerCase();
|
||||
if (t.startsWith("image/")) return <ImageIcon size={18} />;
|
||||
if (t.startsWith("video/")) return <Film size={18} />;
|
||||
if (t.startsWith("audio/")) return <Music size={18} />;
|
||||
if (t === "application/pdf") return <FileText size={18} />;
|
||||
if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return <FileArchive size={18} />;
|
||||
if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return <FileSpreadsheet size={18} />;
|
||||
if (t === "text/calendar") return <Calendar size={18} />;
|
||||
if (t.includes("vcard")) return <UserPlus size={18} />;
|
||||
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
|
||||
return <File size={18} />;
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
||||
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
||||
const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain";
|
||||
return (
|
||||
<>
|
||||
<div className="attachments">
|
||||
{attachments.map((a, i) => {
|
||||
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
||||
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
||||
return (
|
||||
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? "attachment"} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
|
||||
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||
<span className="att-size">{formatSize(a.size)}</span>
|
||||
<span className="att-actions">
|
||||
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
|
||||
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{attachments.length > 1 && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "center" }} onClick={() => { for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
|
||||
<Download size={14} /> Download all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
|
||||
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
|
||||
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
|
||||
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
|
||||
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextAttachment({ url }: { url: string }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
|
||||
}, [url]);
|
||||
return <pre className="code" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { MessageView } from "./MessageView";
|
||||
import type { ListActions } from "./MessageList";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { client } from "@/jmap/client";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
|
||||
interface Props {
|
||||
threadId: Id;
|
||||
mailboxId: Id | null;
|
||||
onBack: () => void;
|
||||
actions: ListActions;
|
||||
onNavigate: (delta: number) => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) {
|
||||
const loadThread = useMail((s) => s.loadThread);
|
||||
const thread = useMail((s) => s.threads[threadId]);
|
||||
const emails = useMail((s) => s.emails);
|
||||
const fullIds = useMail((s) => s.fullIds);
|
||||
const loading = useMail((s) => Boolean(s.loadingThreads[threadId]));
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const labels = settings.labels;
|
||||
const reply = useCompose((s) => s.reply);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<Id, boolean>>({});
|
||||
const [allExpanded, setAllExpanded] = useState(false);
|
||||
const [labelAnchor, setLabelAnchor] = useState<{ x: number; y: number } | null>(null);
|
||||
const moreMenu = useMenu();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const markTimer = useRef<number | null>(null);
|
||||
|
||||
// Load
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
useMail.getState().setOpenThread(threadId);
|
||||
loadThread(threadId).catch((err) => setError((err as Error).message));
|
||||
return () => {
|
||||
if (useMail.getState().openThreadId === threadId) useMail.getState().setOpenThread(null);
|
||||
};
|
||||
}, [threadId, loadThread]);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (!thread) return [] as Email[];
|
||||
const all = thread.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e && fullIds[e.id]));
|
||||
// Conversation view: hide trash/junk messages unless we're in that folder.
|
||||
const mail = useMail.getState();
|
||||
const trash = mail.roleId("trash");
|
||||
const junk = mail.roleId("junk");
|
||||
const filtered = all.filter((e) => {
|
||||
if (mailboxId && (mailboxId === trash || mailboxId === junk)) return true;
|
||||
if (trash && e.mailboxIds[trash]) return false;
|
||||
if (junk && e.mailboxIds[junk]) return false;
|
||||
return true;
|
||||
});
|
||||
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
||||
}, [thread, emails, fullIds, mailboxId]);
|
||||
|
||||
// Default expansion: unread + last message expanded, others collapsed
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
const isExpanded = useCallback(
|
||||
(e: Email) => {
|
||||
if (e.id in expanded) return expanded[e.id]!;
|
||||
if (allExpanded) return true;
|
||||
return !e.keywords.$seen || e.id === lastId || messages.length === 1;
|
||||
},
|
||||
[expanded, allExpanded, lastId, messages.length],
|
||||
);
|
||||
|
||||
// Mark as read after delay
|
||||
useEffect(() => {
|
||||
if (!messages.length) return;
|
||||
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id);
|
||||
if (!unread.length || settings.markReadDelay < 0) return;
|
||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
||||
return () => {
|
||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
|
||||
|
||||
// Scroll last expanded into view on load
|
||||
useEffect(() => {
|
||||
if (!messages.length || !scrollRef.current) return;
|
||||
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(lastId ?? "")}"]`);
|
||||
if (el && messages.length > 1) el.scrollIntoView({ block: "start" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threadId, messages.length > 0]);
|
||||
|
||||
// Keyboard: reply/forward events from MailView
|
||||
useEffect(() => {
|
||||
const onReply = (ev: Event) => {
|
||||
const mode = (ev as CustomEvent<"reply" | "replyAll" | "forward">).detail;
|
||||
const last = messages[messages.length - 1];
|
||||
if (last) void reply(last, mode);
|
||||
};
|
||||
const onNav = (ev: Event) => {
|
||||
const delta = (ev as CustomEvent<number>).detail;
|
||||
const els = Array.from(scrollRef.current?.querySelectorAll<HTMLElement>("[data-msg-id]") ?? []);
|
||||
if (!els.length) return;
|
||||
const top = scrollRef.current!.getBoundingClientRect().top;
|
||||
let idx = els.findIndex((el) => el.getBoundingClientRect().top - top > 8);
|
||||
if (idx < 0) idx = els.length;
|
||||
const target = els[Math.max(0, Math.min(els.length - 1, (delta > 0 ? idx : idx - 2)))];
|
||||
if (target) {
|
||||
const id = target.dataset.msgId!;
|
||||
setExpanded((x) => ({ ...x, [id]: true }));
|
||||
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
window.addEventListener("ihm:reply", onReply);
|
||||
window.addEventListener("ihm:msg-nav", onNav);
|
||||
return () => {
|
||||
window.removeEventListener("ihm:reply", onReply);
|
||||
window.removeEventListener("ihm:msg-nav", onNav);
|
||||
};
|
||||
}, [messages, reply]);
|
||||
|
||||
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
|
||||
const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : [];
|
||||
const anyUnread = messages.some((e) => !e.keywords.$seen);
|
||||
const anyStarred = messages.some((e) => e.keywords.$flagged);
|
||||
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
|
||||
const threadLabels = labels.filter((l) => messages.some((m) => m.keywords[l.keyword]));
|
||||
const threadMailboxes = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of messages) for (const id of Object.keys(m.mailboxIds)) if (mailboxes[id] && id !== mailboxId) set.add(mailboxes[id]!.name);
|
||||
return [...set];
|
||||
}, [messages, mailboxes, mailboxId]);
|
||||
|
||||
const last = messages[messages.length - 1];
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
|
||||
return (
|
||||
<div className="thread-view">
|
||||
<div className="thread-toolbar">
|
||||
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
|
||||
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
|
||||
<span className="tb-sep hide-mobile" />
|
||||
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
|
||||
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
|
||||
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
|
||||
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
|
||||
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
|
||||
{last && accountId && (
|
||||
<MenuItem icon={<Download size={16} />} label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
|
||||
)}
|
||||
</Popover>
|
||||
<div className="thread-nav hide-mobile">
|
||||
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
|
||||
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-scroll" ref={scrollRef}>
|
||||
<div className="thread-subject">
|
||||
<div className="grow">
|
||||
<h1>{subject}</h1>
|
||||
{(threadLabels.length > 0 || threadMailboxes.length > 0) && (
|
||||
<div className="labels">
|
||||
{threadMailboxes.map((n) => <span key={n} className="chip">{n}</span>)}
|
||||
{threadLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
|
||||
</div>
|
||||
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
|
||||
{loading && !messages.length && <Spinner label="Loading conversation…" />}
|
||||
{messages.map((e, i) => (
|
||||
<MessageView
|
||||
key={e.id}
|
||||
email={e}
|
||||
expanded={isExpanded(e)}
|
||||
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
|
||||
isLast={i === messages.length - 1}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{last && (
|
||||
<div className="reply-box">
|
||||
<div className="reply-prompt">
|
||||
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> Reply</button>
|
||||
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> Reply all</button>
|
||||
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{labelAnchor && <LabelPicker ids={rowIds} anchor={labelAnchor} onClose={() => setLabelAnchor(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import type { EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||
const contacts = useContacts();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
if (!contacts.available || !part.blobId) return null;
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await client.fetchBlobText(accountId, part.blobId!, "text/vcard");
|
||||
const book = Object.values(contacts.books).find((b) => b.isDefault) ?? Object.values(contacts.books)[0];
|
||||
if (!book) throw new Error("No address book available");
|
||||
const n = await contacts.importVCard(text, book.id);
|
||||
setDone(true);
|
||||
toast.success(`Added ${n} contact${n === 1 ? "" : "s"}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="vcard-card">
|
||||
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
||||
<div className="hint">vCard attachment</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSession } from "@/store/session";
|
||||
import { client } from "@/jmap/client";
|
||||
|
||||
export function AboutSettings() {
|
||||
const session = useSession((s) => s.session);
|
||||
const caps = Object.keys(session?.capabilities ?? {});
|
||||
return (
|
||||
<div>
|
||||
<h1>About ihasmail</h1>
|
||||
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">Stalwart Mail Server</a>, built on JMAP.</p>
|
||||
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
|
||||
<img src="/img/logo.png" alt="ihasmail" width={96} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
|
||||
<div className="hint">GPL-3.0-or-later · <a href="https://github.com/LINUXexpert-org/ihasmail" target="_blank" rel="noreferrer">github.com/LINUXexpert-org/ihasmail</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Server</h2>
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
|
||||
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
||||
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
|
||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Server capabilities</h2>
|
||||
<div className="row wrap gap-4">
|
||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
|
||||
const ACCENTS = [
|
||||
{ id: "teal", color: "#0f766e" },
|
||||
{ id: "blue", color: "#2563eb" },
|
||||
{ id: "purple", color: "#7c3aed" },
|
||||
{ id: "rose", color: "#e11d48" },
|
||||
{ id: "orange", color: "#ea580c" },
|
||||
{ id: "green", color: "#16a34a" },
|
||||
];
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
return (
|
||||
<div>
|
||||
<h1>Appearance</h1>
|
||||
<p className="lead">Make ihasmail yours.</p>
|
||||
<h2>Theme</h2>
|
||||
<div className="theme-grid">
|
||||
{(["system", "light", "dark"] as const).map((t) => (
|
||||
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
|
||||
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
|
||||
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2>Accent color</h2>
|
||||
<div className="swatches">
|
||||
{ACCENTS.map((a) => (
|
||||
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
|
||||
))}
|
||||
</div>
|
||||
<h2>Density & text</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Display density</label>
|
||||
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="cozy">Cozy (default)</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Text size</label>
|
||||
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Sidebar</h2>
|
||||
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
|
||||
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />
|
||||
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label="Collapse sidebar to icons" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
export function CalendarSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
return (
|
||||
<div>
|
||||
<h1>Calendar & contacts</h1>
|
||||
<p className="lead">Defaults for the calendar views and new events.</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Default view</label>
|
||||
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="agenda">Agenda</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default event length</label>
|
||||
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="45">45 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
<option value="90">1.5 hours</option>
|
||||
<option value="120">2 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default reminder</label>
|
||||
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
|
||||
<option value="-1">None</option>
|
||||
<option value="0">At time of event</option>
|
||||
<option value="5">5 minutes before</option>
|
||||
<option value="10">10 minutes before</option>
|
||||
<option value="15">15 minutes before</option>
|
||||
<option value="30">30 minutes before</option>
|
||||
<option value="60">1 hour before</option>
|
||||
<option value="1440">1 day before</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Colour categories</h2>
|
||||
<p className="hint">Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.</p>
|
||||
{s.eventCategories.map((c, i) => (
|
||||
<div key={c.name} className="card">
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
|
||||
<h3>{c.name}</h3>
|
||||
<button className="icon-btn sm" title="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}>✎</button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
|
||||
|
||||
<h2>Working hours</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Working hours start</label>
|
||||
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
|
||||
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Working hours end</label>
|
||||
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
|
||||
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Week starts on</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">Monday</option>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Code, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { describeRule, newRule, rulesToSieve, type SieveRule } from "@/lib/sieve";
|
||||
import { RuleDialog } from "./RuleDialog";
|
||||
import { saveAndApply } from "../mail/FilterFromMessage";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { Switch, Spinner } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { SieveScript } from "@/jmap/types";
|
||||
|
||||
export function FiltersSettings() {
|
||||
const sieve = useSieve();
|
||||
const [tab, setTab] = useState<"rules" | "scripts">("rules");
|
||||
useEffect(() => {
|
||||
if (sieve.available && !sieve.scripts.length && !sieve.loading) void sieve.load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sieve.available]);
|
||||
|
||||
if (!sieve.available) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Filters & rules</h1>
|
||||
<p className="lead">Sieve filtering is not available for this account.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Filters & rules</h1>
|
||||
<p className="lead">Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.</p>
|
||||
<div className="view-switch" style={{ marginBottom: 16 }}>
|
||||
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
|
||||
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
|
||||
</div>
|
||||
{sieve.loading && !sieve.scripts.length ? <Spinner /> : tab === "rules" ? <RulesEditor /> : <ScriptsEditor />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesEditor() {
|
||||
const sieve = useSieve();
|
||||
const { script, rules, content } = sieve.rules();
|
||||
const [local, setLocal] = useState<SieveRule[] | null>(null);
|
||||
const [editing, setEditing] = useState<SieveRule | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const list = local ?? rules ?? [];
|
||||
const dirty = local !== null;
|
||||
const inbox = useMail((s) => { const id = s.roleId("inbox"); return id ? s.mailboxes[id] : undefined; });
|
||||
const activeIsOther = script && script.name !== "ihasmail" && script.isActive;
|
||||
|
||||
const save = async (next: SieveRule[]) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await sieve.saveRules(next);
|
||||
setLocal(null);
|
||||
toast.success("Filters saved");
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filters: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (rules === null) {
|
||||
return (
|
||||
<div className="warn-box">
|
||||
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script “{script?.name}” was written by hand.</b></div>
|
||||
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>Scripts</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>Start with rules</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</div>}
|
||||
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>No filters yet</h3><p>Create a rule to move newsletters to a folder, flag important senders, or forward mail.</p></div>}
|
||||
{list.map((r, i) => (
|
||||
<div key={r.id} className={`rule-card ${r.enabled ? "" : "disabled"}`}>
|
||||
<div className="row">
|
||||
<Switch checked={r.enabled} onChange={(v) => setLocal(list.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} />
|
||||
<div className="grow" style={{ cursor: "pointer", minWidth: 0 }} onClick={() => setEditing(r)}>
|
||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||
<div className="hint truncate">{describeRule(r)}</div>
|
||||
</div>
|
||||
<button className="icon-btn sm" disabled={i === 0} aria-label="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
|
||||
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label="Move down" onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
|
||||
<span className="spacer" />
|
||||
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>Discard changes</button>}
|
||||
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
|
||||
</div>
|
||||
{content && (
|
||||
<details style={{ marginTop: 20 }}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
|
||||
<pre className="code" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{editing && (
|
||||
<RuleDialog
|
||||
rule={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
applyMailbox={inbox ? { id: inbox.id, name: inbox.name } : null}
|
||||
onSave={(r, applyNow) => {
|
||||
const exists = list.some((x) => x.id === r.id);
|
||||
const next = exists ? list.map((x) => (x.id === r.id ? r : x)) : [...list, r];
|
||||
setEditing(null);
|
||||
if (applyNow && inbox) {
|
||||
// Save immediately so the rule is live, then apply it to the Inbox.
|
||||
setLocal(null);
|
||||
void saveAndApply(r, next.filter((x) => x.id !== r.id), inbox.id);
|
||||
} else setLocal(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsEditor() {
|
||||
const sieve = useSieve();
|
||||
const [sel, setSel] = useState<SieveScript | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
|
||||
const open = async (s: SieveScript | null) => {
|
||||
setSel(s);
|
||||
setValidation(null);
|
||||
if (s) {
|
||||
setName(s.name);
|
||||
setContent(await sieve.getContent(s.id));
|
||||
} else {
|
||||
setName("");
|
||||
setContent('require ["fileinto"];\n\n');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (activate: boolean) => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Script name is required");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const err = await sieve.validate(content);
|
||||
setValidation(err);
|
||||
if (err) {
|
||||
toast.error("Script has errors");
|
||||
return;
|
||||
}
|
||||
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
|
||||
toast.success("Script saved");
|
||||
setSel(null);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sel !== null || name !== "" || content !== "") {
|
||||
if (sel !== null || name !== "" || content !== "") {
|
||||
return (
|
||||
<div>
|
||||
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
|
||||
<div className="field">
|
||||
<label>Sieve source</label>
|
||||
<textarea className="code" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
|
||||
</div>
|
||||
{validation && <div className="error-box mb-16">{validation}</div>}
|
||||
<div className="row">
|
||||
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>Cancel</button>
|
||||
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="hint">Advanced: manage raw Sieve scripts. Only one script can be active at a time.</p>
|
||||
{sieve.scripts.map((s) => (
|
||||
<div key={s.id} className="card">
|
||||
<div className="card-head">
|
||||
<h3>{s.name} {s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
|
||||
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
|
||||
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
|
||||
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={async () => { const n = await promptDialog({ title: "New script", placeholder: "Script name" }); if (n) { setName(n); setContent('require ["fileinto"];\n\n'); } }}><Plus size={16} /> New script</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Eye, EyeOff, Folder, Pencil, Plus, Share2, Trash2, Inbox } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
export function FoldersSettings() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const [share, setShare] = useState<Mailbox | null>(null);
|
||||
const list = useMemo(() => Object.values(mailboxes).map((m) => ({ m, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
|
||||
const quotas = useMail((s) => s.quotas);
|
||||
const q = quotas.find((x) => x.resourceType === "octets");
|
||||
|
||||
const create = async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
|
||||
let parentId: string | null = null;
|
||||
for (const part of parts) {
|
||||
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
|
||||
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
|
||||
}
|
||||
toast.success("Folder created");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Folders</h1>
|
||||
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
|
||||
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{list.map(({ m, path }) => (
|
||||
<tr key={m.id}>
|
||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">hidden</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
||||
<td>{m.totalEmails.toLocaleString()}</td>
|
||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||
<td>
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
|
||||
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{share && <ShareDialog kind="Mailbox" id={share.id} name={share.name} shareWith={share.shareWith ?? null} onClose={() => setShare(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const reset = useSettings((st) => st.reset);
|
||||
const exportJson = useSettings((st) => st.exportJson);
|
||||
const importJson = useSettings((st) => st.importJson);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>General</h1>
|
||||
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
|
||||
|
||||
<h2>Reading</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Reading pane</label>
|
||||
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
|
||||
<option value="right">Right of the list</option>
|
||||
<option value="bottom">Below the list</option>
|
||||
<option value="off">Off (open messages full width)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mark as read</label>
|
||||
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
|
||||
<option value="0">Immediately when opened</option>
|
||||
<option value="2">After 2 seconds</option>
|
||||
<option value="5">After 5 seconds</option>
|
||||
<option value="-1">Never automatically</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>After archiving or deleting</label>
|
||||
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
|
||||
<option value="list">Go back to the list</option>
|
||||
<option value="older">Open the next (older) conversation</option>
|
||||
<option value="newer">Open the previous (newer) conversation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Remote images</label>
|
||||
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
||||
<option value="ask">Ask before showing (recommended)</option>
|
||||
<option value="contacts">Show automatically from my contacts</option>
|
||||
<option value="always">Always show</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label="Conversation view" hint="Group messages from the same thread together." />
|
||||
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label="Show message snippets" hint="Preview the first line of each message in the list." />
|
||||
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label="Show sender avatars" />
|
||||
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
|
||||
|
||||
<h2>Composing</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Default format</label>
|
||||
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
|
||||
<option value="html">Rich text (HTML)</option>
|
||||
<option value="text">Plain text</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Undo send window</label>
|
||||
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||
<option value="0">Off</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="8">8 seconds</option>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
|
||||
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
|
||||
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
|
||||
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
|
||||
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
|
||||
|
||||
<h2>Locale</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Time zone</label>
|
||||
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
|
||||
<option value="">Browser default ({browserTimeZone})</option>
|
||||
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Week starts on</label>
|
||||
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
|
||||
<option value="1">Monday</option>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Backup</h2>
|
||||
<div className="row wrap">
|
||||
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
|
||||
<label className="btn">
|
||||
Import settings
|
||||
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Trash2, Star } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { Identity } from "@/jmap/types";
|
||||
import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { parseAddressList, formatAddressList } from "@/lib/address";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
||||
import { buildMarkerSignature, compactHtml, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
||||
|
||||
export function IdentitiesSettings() {
|
||||
const identities = useMail((s) => s.identities);
|
||||
const load = useMail((s) => s.loadIdentities);
|
||||
const accountId = useMail((s) => s.accountId);
|
||||
const setDefault = useMail((s) => s.setDefaultIdentity);
|
||||
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
||||
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Identities & signatures</h1>
|
||||
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
|
||||
{identities.map((i) => (
|
||||
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
|
||||
<div className="card-head">
|
||||
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
|
||||
{i.id !== defaultId && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
||||
)}
|
||||
{i.mayDelete && (
|
||||
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
)}
|
||||
</div>
|
||||
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
||||
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
||||
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
|
||||
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
|
||||
const [name, setName] = useState(identity.name ?? "");
|
||||
const [email, setEmail] = useState(identity.email ?? "");
|
||||
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
|
||||
const [html, setHtml] = useState(identity.htmlSignature || (identity.textSignature ? identity.textSignature.replace(/\n/g, "<br>") : ""));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ref = useRef<RichEditorHandle>(null);
|
||||
const compact = compactHtml(sanitizeEditorHtml(html));
|
||||
const sigLen = compact.length;
|
||||
const tooLong = sigLen > SIGNATURE_LIMIT || htmlToText(compact).length > SIGNATURE_LIMIT;
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// 1) pasted pictures → stored files, 2) strip cruft, 3) fall back to a stored full copy.
|
||||
const externalized = await externalizeDataImages(sanitizeEditorHtml(html));
|
||||
const clean = compactHtml(externalized);
|
||||
let htmlSignature = clean;
|
||||
let textSignature = htmlToText(clean);
|
||||
if (clean.length > SIGNATURE_LIMIT || textSignature.length > SIGNATURE_LIMIT) {
|
||||
const blobId = await storeSignatureHtml(clean);
|
||||
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, clean));
|
||||
}
|
||||
const patch: Partial<Identity> = {
|
||||
name,
|
||||
replyTo: replyTo.trim() ? parseAddressList(replyTo) : null,
|
||||
htmlSignature,
|
||||
textSignature,
|
||||
};
|
||||
if (!identity.id) patch.email = email.trim();
|
||||
await useMail.getState().saveIdentity(identity.id ?? null, patch);
|
||||
toast.success("Identity saved");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
|
||||
<div className="field">
|
||||
<label>Signature</label>
|
||||
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
|
||||
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span className="hint">Images are stored in your Files (folder “ihasmail”) and embedded when you send.</span>
|
||||
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
||||
</div>
|
||||
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-character limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
|
||||
export function LabelsSettings() {
|
||||
const labels = useSettings((s) => s.settings.labels);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
|
||||
if (!name?.trim()) return;
|
||||
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
|
||||
if (labels.some((l) => l.keyword === keyword)) return;
|
||||
update({ labels: [...labels, { keyword, name: name.trim(), color: CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]! }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Labels</h1>
|
||||
<p className="lead">Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.</p>
|
||||
{labels.map((l) => (
|
||||
<div key={l.keyword} className="card">
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: l.color, width: 14, height: 14 }} />
|
||||
{editing === l.keyword ? (
|
||||
<input className="input sm" autoFocus defaultValue={l.name} onBlur={(e) => { update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, name: e.target.value || x.name } : x)) }); setEditing(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} style={{ width: 240 }} />
|
||||
) : (
|
||||
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
|
||||
)}
|
||||
<button className="icon-btn sm danger" aria-label="Delete label" onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => void add()}><Plus size={16} /> New label</button>
|
||||
<p className="hint mt-8">Tip: press <kbd className="kbd">l</kbd> on a conversation to apply labels. Search with <code>label:name</code>.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const pushConnected = useSession((st) => st.pushConnected);
|
||||
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
|
||||
useEffect(() => {
|
||||
if ("Notification" in window) setPerm(Notification.permission);
|
||||
}, [s.desktopNotifications]);
|
||||
return (
|
||||
<div>
|
||||
<h1>Notifications</h1>
|
||||
<p className="lead">Live updates are delivered via JMAP push ({pushConnected ? "connected" : "reconnecting…"}).</p>
|
||||
<Switch
|
||||
checked={s.desktopNotifications}
|
||||
onChange={async (v) => {
|
||||
if (v) {
|
||||
const p = await requestNotificationPermission();
|
||||
setPerm(p);
|
||||
if (p !== "granted") return;
|
||||
}
|
||||
update({ desktopNotifications: v });
|
||||
}}
|
||||
label="Desktop notifications for new mail"
|
||||
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
|
||||
disabled={perm === "denied" || perm === "unsupported"}
|
||||
/>
|
||||
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
|
||||
</div>
|
||||
<p className="hint mt-8">The tab title and favicon always show your unread Inbox count.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve";
|
||||
import { Dialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
export interface RuleDialogProps {
|
||||
rule: SieveRule;
|
||||
onClose: () => void;
|
||||
/** Called with the rule and whether the user asked to apply it to existing messages now. */
|
||||
onSave: (r: SieveRule, applyNow: boolean) => void;
|
||||
/** When set, offers "Also apply to existing messages in <folder>". */
|
||||
applyMailbox?: { id: Id; name: string } | null;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function RuleDialog({ rule, onClose, onSave, applyMailbox, title, saveLabel }: RuleDialogProps) {
|
||||
const [r, setR] = useState<SieveRule>(rule);
|
||||
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox));
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||
const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
|
||||
const setTest = (i: number, t: SieveTest) => setR({ ...r, tests: r.tests.map((x, j) => (j === i ? t : x)) });
|
||||
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
|
||||
{applyMailbox && (
|
||||
<label className="check left" style={{ marginRight: "auto" }}>
|
||||
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
|
||||
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
|
||||
</label>
|
||||
)}
|
||||
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
|
||||
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<span className="label">When</span>
|
||||
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
|
||||
<option value="allof">all of the following match</option>
|
||||
<option value="anyof">any of the following match</option>
|
||||
</select>
|
||||
</div>
|
||||
{r.tests.map((t, i) => (
|
||||
<div key={i} className="rule-row">
|
||||
<select className="select" value={t.type === "true" ? "true" : t.type === "size" ? "size" : t.type === "body" ? "body" : t.type === "address" ? "address" : HEADER_CHOICES.some((h) => h.value === t.header) ? t.header : "__custom__"} onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "size") setTest(i, { type: "size", op: "over", value: 1024 * 1024 });
|
||||
else if (v === "body") setTest(i, { type: "body", op: "contains", value: "" });
|
||||
else if (v === "true") setTest(i, { type: "true" });
|
||||
else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" });
|
||||
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
|
||||
}}>
|
||||
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
|
||||
<option value="address">Sender domain</option>
|
||||
<option value="size">Message size</option>
|
||||
<option value="body">Body text</option>
|
||||
<option value="true">Always (all messages)</option>
|
||||
</select>
|
||||
{t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__") ? (
|
||||
<input className="input" placeholder="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
|
||||
) : t.type === "size" ? (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
|
||||
) : t.type === "body" ? (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
|
||||
) : t.type === "true" ? <span /> : (
|
||||
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
|
||||
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{t.type === "size" ? (
|
||||
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
|
||||
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
|
||||
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
|
||||
)}
|
||||
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
|
||||
|
||||
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
|
||||
{r.actions.map((a, i) => (
|
||||
<div key={i} className="rule-row actions">
|
||||
<select className="select" value={a.type} onChange={(e) => {
|
||||
const v = e.target.value as SieveAction["type"];
|
||||
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
|
||||
setAction(i, next);
|
||||
}}>
|
||||
<option value="fileinto">Move to folder</option>
|
||||
<option value="markread">Mark as read</option>
|
||||
<option value="flag">Star</option>
|
||||
<option value="addflag">Add label / keyword</option>
|
||||
<option value="redirect">Forward to</option>
|
||||
<option value="keep">Keep in Inbox</option>
|
||||
<option value="discard">Delete</option>
|
||||
<option value="reject">Reject with message</option>
|
||||
<option value="stop">Stop processing more rules</option>
|
||||
</select>
|
||||
{a.type === "fileinto" ? (
|
||||
<div className="row">
|
||||
<select
|
||||
className="select"
|
||||
value={a.mailbox}
|
||||
onChange={async (e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "__new__") {
|
||||
// Create a folder on the fly ("Parent/Child" creates nested folders).
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const mail = useMail.getState();
|
||||
const parts = name.split("/").map((x) => x.trim()).filter(Boolean);
|
||||
let parentId: string | null = null;
|
||||
for (const part of parts) {
|
||||
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
|
||||
parentId = existing ? existing.id : await mail.createMailbox(part, parentId);
|
||||
}
|
||||
const path = useMail.getState().mailboxPath(parentId!);
|
||||
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
|
||||
toast.success(`Folder “${path}” created`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setAction(i, { ...a, mailbox: v, mailboxId: folders.find((f) => f.path === v)?.id });
|
||||
}}
|
||||
>
|
||||
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
|
||||
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
|
||||
<option value="__new__">+ New folder…</option>
|
||||
</select>
|
||||
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||
</div>
|
||||
) : a.type === "redirect" ? (
|
||||
<div className="row">
|
||||
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
|
||||
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
|
||||
</div>
|
||||
) : a.type === "reject" ? (
|
||||
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
|
||||
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
|
||||
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
|
||||
) : <span />}
|
||||
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { useSession } from "@/store/session";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||
const [current, setCurrent] = useState<string>("");
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([]));
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
<h1>Security & sessions</h1>
|
||||
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
|
||||
<h2>Active webmail sessions</h2>
|
||||
{rows === null ? <p className="hint">Loading…</p> : (
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
|
||||
<td className="mono small">{r.ip}</td>
|
||||
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
|
||||
<td>{formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
|
||||
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
|
||||
</div>
|
||||
<h2>Password & two-factor</h2>
|
||||
<p className="hint">Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortUa(ua: string): string {
|
||||
const browser = /Firefox\/(\d+)/.exec(ua) ? `Firefox ${/Firefox\/(\d+)/.exec(ua)![1]}` : /Edg\/(\d+)/.exec(ua) ? `Edge ${/Edg\/(\d+)/.exec(ua)![1]}` : /Chrome\/(\d+)/.exec(ua) ? `Chrome ${/Chrome\/(\d+)/.exec(ua)![1]}` : /Safari\/(\d+)/.exec(ua) ? "Safari" : "Browser";
|
||||
const os = /Windows/.test(ua) ? "Windows" : /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS" : /Mac OS/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
|
||||
return `${browser}${os ? ` on ${os}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ArrowLeft, Bell, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { AppearanceSettings } from "./AppearanceSettings";
|
||||
import { IdentitiesSettings } from "./IdentitiesSettings";
|
||||
import { FoldersSettings } from "./FoldersSettings";
|
||||
import { LabelsSettings } from "./LabelsSettings";
|
||||
import { TemplatesSettings } from "./TemplatesSettings";
|
||||
import { NotificationsSettings } from "./NotificationsSettings";
|
||||
import { SecuritySettings } from "./SecuritySettings";
|
||||
import { AboutSettings } from "./AboutSettings";
|
||||
import { ShortcutsSettings } from "./ShortcutsSettings";
|
||||
import { CalendarSettings } from "./CalendarSettings";
|
||||
|
||||
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
|
||||
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
|
||||
|
||||
const SECTIONS: Array<{ id: string; label: string; icon: ReactNode; el: ReactNode }> = [
|
||||
{ id: "general", label: "General", icon: <SettingsIcon size={18} />, el: <GeneralSettings /> },
|
||||
{ id: "appearance", label: "Appearance", icon: <Palette size={18} />, el: <AppearanceSettings /> },
|
||||
{ id: "identities", label: "Identities & signatures", icon: <PenLine size={18} />, el: <IdentitiesSettings /> },
|
||||
{ id: "filters", label: "Filters & rules", icon: <Filter size={18} />, el: <FiltersSettings /> },
|
||||
{ id: "vacation", label: "Out of office", icon: <Plane size={18} />, el: <VacationSettings /> },
|
||||
{ id: "folders", label: "Folders", icon: <Folder size={18} />, el: <FoldersSettings /> },
|
||||
{ id: "labels", label: "Labels", icon: <Tag size={18} />, el: <LabelsSettings /> },
|
||||
{ id: "templates", label: "Templates", icon: <LayoutTemplate size={18} />, el: <TemplatesSettings /> },
|
||||
{ id: "calendar", label: "Calendar & contacts", icon: <Calendar size={18} />, el: <CalendarSettings /> },
|
||||
{ id: "notifications", label: "Notifications", icon: <Bell size={18} />, el: <NotificationsSettings /> },
|
||||
{ id: "security", label: "Security & sessions", icon: <ShieldCheck size={18} />, el: <SecuritySettings /> },
|
||||
{ id: "shortcuts", label: "Keyboard shortcuts", icon: <Keyboard size={18} />, el: <ShortcutsSettings /> },
|
||||
{ id: "about", label: "About", icon: <Info size={18} />, el: <AboutSettings /> },
|
||||
];
|
||||
|
||||
export function SettingsView({ section }: { section?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
const current = SECTIONS.find((s) => s.id === section);
|
||||
return (
|
||||
<div className={`settings-layout ${section ? "section" : "root"}`}>
|
||||
<nav className="settings-nav" aria-label="Settings">
|
||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
|
||||
{SECTIONS.map((s) => (
|
||||
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
||||
{s.icon}
|
||||
<span className="nav-label">{s.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
|
||||
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">Address books</span></Link>
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
{section && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/settings")}>
|
||||
<ArrowLeft size={16} /> All settings
|
||||
</button>
|
||||
)}
|
||||
<Suspense fallback={<Spinner />}>{current ? current.el : <GeneralSettings />}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id, Principal } from "@/jmap/types";
|
||||
|
||||
type Kind = "Mailbox" | "Calendar" | "AddressBook";
|
||||
|
||||
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
|
||||
Mailbox: [
|
||||
{ key: "mayReadItems", label: "Read" },
|
||||
{ key: "mayAddItems", label: "Add" },
|
||||
{ key: "mayRemoveItems", label: "Remove" },
|
||||
{ key: "maySetSeen", label: "Mark read" },
|
||||
{ key: "maySetKeywords", label: "Flag" },
|
||||
{ key: "mayCreateChild", label: "Create subfolders" },
|
||||
{ key: "mayRename", label: "Rename" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
{ key: "maySubmit", label: "Send" },
|
||||
],
|
||||
Calendar: [
|
||||
{ key: "mayReadFreeBusy", label: "See free/busy" },
|
||||
{ key: "mayReadItems", label: "Read events" },
|
||||
{ key: "mayWriteAll", label: "Edit all" },
|
||||
{ key: "mayWriteOwn", label: "Edit own" },
|
||||
{ key: "mayUpdatePrivate", label: "Private props" },
|
||||
{ key: "mayRSVP", label: "RSVP" },
|
||||
{ key: "mayShare", label: "Share" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
],
|
||||
AddressBook: [
|
||||
{ key: "mayRead", label: "Read" },
|
||||
{ key: "mayWrite", label: "Write" },
|
||||
{ key: "mayShare", label: "Share" },
|
||||
{ key: "mayDelete", label: "Delete" },
|
||||
],
|
||||
};
|
||||
|
||||
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
|
||||
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
|
||||
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
|
||||
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
|
||||
};
|
||||
|
||||
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
|
||||
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
|
||||
const principals = useContacts((s) => s.principals);
|
||||
const loadPrincipals = useContacts((s) => s.loadPrincipals);
|
||||
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
|
||||
const [pick, setPick] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
void loadPrincipals();
|
||||
}, [loadPrincipals]);
|
||||
|
||||
const available = principals.filter((p) => !rights[p.id]);
|
||||
const add = (p: Principal, preset: "reader" | "editor") => {
|
||||
const r: Record<string, boolean> = {};
|
||||
for (const k of PRESETS[kind][preset]) r[k] = true;
|
||||
setRights({ ...rights, [p.id]: r });
|
||||
setPick("");
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
|
||||
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
toast.success("Sharing updated");
|
||||
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
|
||||
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
|
||||
if (kind === "AddressBook") void useContacts.getState().loadBooks();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
|
||||
{!principals.length ? (
|
||||
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
<option value="">Add a person or group…</option>
|
||||
{available.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}{p.email ? ` <${p.email}>` : ""}{p.type !== "individual" ? ` (${p.type})` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
|
||||
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
|
||||
</div>
|
||||
{Object.entries(rights).map(([pid, r]) => {
|
||||
const p = principals.find((x) => x.id === pid);
|
||||
return (
|
||||
<div key={pid} className="card">
|
||||
<div className="card-head">
|
||||
<h3>{p?.name ?? pid}{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
|
||||
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div className="row wrap" style={{ marginTop: 8 }}>
|
||||
{RIGHTS[kind].map((rt) => (
|
||||
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
|
||||
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
|
||||
<span className="small">{rt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { Kbd } from "@/ui/misc";
|
||||
|
||||
export function ShortcutsSettings() {
|
||||
const list = useMemo(() => keyboard.list(), []);
|
||||
const groups = useMemo(() => {
|
||||
const g = new Map<string, typeof list>();
|
||||
for (const b of list) {
|
||||
const arr = g.get(b.group) ?? [];
|
||||
arr.push(b);
|
||||
g.set(b.group, arr);
|
||||
}
|
||||
return [...g.entries()];
|
||||
}, [list]);
|
||||
return (
|
||||
<div>
|
||||
<h1>Keyboard shortcuts</h1>
|
||||
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
|
||||
<div className="shortcut-grid">
|
||||
{groups.map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
{items.map((b) => (
|
||||
<div key={b.keys} className="shortcut-row"><span>{b.description}</span><Kbd keys={b.keys} /></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!groups.length && <p className="hint">Open the Mail view to see all shortcuts.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings, type Template } from "@/store/settings";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { RichEditor } from "../compose/RichEditor";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
|
||||
export function TemplatesSettings() {
|
||||
const templates = useSettings((s) => s.settings.templates);
|
||||
const update = useSettings((s) => s.update);
|
||||
const [editing, setEditing] = useState<Template | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<h1>Templates</h1>
|
||||
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
|
||||
<div className="card-head">
|
||||
<h3>{t.name}</h3>
|
||||
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{t.subject && <div className="hint">Subject: {t.subject}</div>}
|
||||
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
|
||||
{editing && (
|
||||
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
|
||||
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Body</label>
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
|
||||
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
||||
import { client, CAP } from "@/jmap/client";
|
||||
|
||||
export function VacationSettings() {
|
||||
const vacation = useMail((s) => s.vacation);
|
||||
const load = useMail((s) => s.loadVacation);
|
||||
const save = useMail((s) => s.saveVacation);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const available = client.hasCapability(CAP.vacation);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
if (!vacation) return;
|
||||
setEnabled(vacation.isEnabled);
|
||||
setSubject(vacation.subject ?? "");
|
||||
setBody(vacation.textBody ?? "");
|
||||
setFrom(vacation.fromDate ? toInputDateTime(new Date(vacation.fromDate)) : "");
|
||||
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
|
||||
}, [vacation]);
|
||||
|
||||
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await save({
|
||||
isEnabled: enabled,
|
||||
subject: subject || null,
|
||||
textBody: body || null,
|
||||
htmlBody: null,
|
||||
fromDate: from ? toUTCDate(fromInputDateTime(from)) : null,
|
||||
toDate: to ? toUTCDate(fromInputDateTime(to)) : null,
|
||||
});
|
||||
toast.success(enabled ? "Auto-reply is on" : "Auto-reply saved");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Out of office</h1>
|
||||
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
|
||||
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
|
||||
<div className="field-row mt-16">
|
||||
<div className="field"><label>Starts (optional)</label><input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} /></div>
|
||||
<div className="field"><label>Ends (optional)</label><input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
|
||||
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until … and will reply when I'm back." /></div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": false,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8080",
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["wouter", "zustand", "dompurify", "@tanstack/react-virtual"],
|
||||
icons: ["lucide-react"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||