@wtfalch/email 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,52 @@
1
1
  import type { MailClient } from '../client.ts';
2
+ import type { MailError } from '../errors.ts';
3
+ /**
4
+ * Where the reader is: which mailbox, which conversation.
5
+ *
6
+ * Its own type because an application that puts this in the URL has to be
7
+ * able to name it.
8
+ */
9
+ export type MailLocation = {
10
+ mailboxId: string | null;
11
+ threadId: string | null;
12
+ };
2
13
  export type MailProps = {
3
14
  client: MailClient;
15
+ /**
16
+ * Drive the selection from outside — from the URL, almost always.
17
+ *
18
+ * Omit it and the component keeps its own, which is right for a gallery or
19
+ * an embed. Supply it with `onNavigate` and the two selections become the
20
+ * application's: `/inbox` and `/thread/abc` can be real addresses that
21
+ * survive a reload, appear in history and can be sent to someone.
22
+ *
23
+ * A partially controlled component is a trap, so this is all or nothing:
24
+ * pass `location` and you own both fields.
25
+ */
26
+ location?: MailLocation;
27
+ /** Called when something in here wants to move. Required with `location`,
28
+ * because without it the controlled selection could never change. */
29
+ onNavigate?: (to: MailLocation) => void;
30
+ /**
31
+ * The right-hand end of the header: who is signed in, and the way out.
32
+ *
33
+ * A slot rather than anything this component builds, because `Mail` does
34
+ * not know how the person got here. It has no idea whether there is a
35
+ * session, a token, an issuer or a sign-out — and a mail view that grew
36
+ * opinions about authentication would be a mail view nobody could embed.
37
+ */
38
+ account?: React.ReactNode;
39
+ /**
40
+ * Called when any of the fetches behind this view fails.
41
+ *
42
+ * The views already say "could not load" for themselves; this is for the
43
+ * application to act on a *class* of failure it alone can answer. The one
44
+ * that matters is 401: the token expired mid-session, and only the thing
45
+ * that obtained it can get another.
46
+ */
47
+ onError?: (error: MailError) => void;
4
48
  /** Now, for the list's relative times. Passed in so stories hold still. */
5
49
  now?: Date;
6
50
  className?: string;
7
51
  };
8
- export declare function Mail({ client, now, className }: MailProps): import("react").JSX.Element;
52
+ export declare function Mail({ client, location, onNavigate, account, onError, now, className, }: MailProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Command, Icon, Input, Modal, SplitPane } from '@wtfalch/design';
3
- import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { forwardDraft, replyDraft } from "../drafts.js";
5
5
  import { findRole } from "../mailboxes.js";
6
6
  import { send } from "../submit.js";
@@ -27,9 +27,25 @@ import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./h
27
27
  * mailboxes as "go to" entries, so moving around never requires the mouse.
28
28
  */
29
29
  const PAGE = 50;
30
- export function Mail({ client, now, className }) {
31
- const [mailboxId, setMailboxId] = useState(null);
32
- const [threadId, setThreadId] = useState(null);
30
+ export function Mail({ client, location, onNavigate, account, onError, now, className, }) {
31
+ const [ownMailboxId, setOwnMailboxId] = useState(null);
32
+ const [ownThreadId, setOwnThreadId] = useState(null);
33
+ /* Controlled when a location was given, and then entirely: reading one
34
+ field from the caller and the other from here is how a component ends up
35
+ with two disagreeing ideas of where it is. */
36
+ const controlled = location !== undefined;
37
+ const mailboxId = controlled ? location.mailboxId : ownMailboxId;
38
+ const threadId = controlled ? location.threadId : ownThreadId;
39
+ const navigate = useCallback((to) => {
40
+ if (controlled)
41
+ onNavigate?.(to);
42
+ else {
43
+ setOwnMailboxId(to.mailboxId);
44
+ setOwnThreadId(to.threadId);
45
+ }
46
+ }, [controlled, onNavigate]);
47
+ const setMailboxId = useCallback((next) => navigate({ mailboxId: next, threadId: null }), [navigate]);
48
+ const setThreadId = useCallback((next) => navigate({ mailboxId, threadId: next }), [navigate, mailboxId]);
33
49
  const [position, setPosition] = useState(0);
34
50
  const [query, setQuery] = useState('');
35
51
  const [text, setText] = useState('');
@@ -48,7 +64,7 @@ export function Mail({ client, now, className }) {
48
64
  const inbox = findRole(boxes.data, 'inbox') ?? boxes.data[0];
49
65
  if (inbox)
50
66
  setMailboxId(inbox.id);
51
- }, [boxes.data, mailboxId]);
67
+ }, [boxes.data, mailboxId, setMailboxId]);
52
68
  const threads = useThreads({ mailboxId: mailboxId ?? undefined, text: query, position, limit: PAGE, calculateTotal: true }, client);
53
69
  const thread = useThread(threadId, client);
54
70
  const refresh = useCallback(() => {
@@ -56,14 +72,24 @@ export function Mail({ client, now, className }) {
56
72
  threads.reload();
57
73
  }, [boxes.reload, threads.reload]);
58
74
  usePush(refresh, client);
75
+ /* Errors reach the application, but only when they change: these hooks hold
76
+ their last error while a retry is in flight, so forwarding on every
77
+ render would call `onError` sixty times for one dead token. */
78
+ const failure = boxes.error ?? threads.error ?? thread.error ?? identities.error;
79
+ const reported = useRef(undefined);
80
+ useEffect(() => {
81
+ if (!failure || reported.current === failure)
82
+ return;
83
+ reported.current = failure;
84
+ onError?.(failure);
85
+ }, [failure, onError]);
59
86
  const go = useCallback((mailbox) => {
60
- setMailboxId(mailbox.id);
61
- setThreadId(null);
87
+ navigate({ mailboxId: mailbox.id, threadId: null });
62
88
  setPosition(0);
63
89
  setQuery('');
64
90
  setText('');
65
- }, []);
66
- const openThread = useCallback((summary) => setThreadId(summary.id), []);
91
+ }, [navigate]);
92
+ const openThread = useCallback((summary) => setThreadId(summary.id), [setThreadId]);
67
93
  /* Cmd-K, and Escape out of a search. Bound on the window rather than on a
68
94
  container, because the palette has to open from wherever focus is --
69
95
  including from inside the message being read. */
@@ -145,5 +171,5 @@ export function Mail({ client, now, className }) {
145
171
  return;
146
172
  setText('');
147
173
  setQuery('');
148
- } }) }), _jsx(Button, { kind: "ghost", onClick: () => setPaletteOpen(true), "aria-label": "Open the palette", children: _jsx(Icon, { name: "bolt" }) })] }), _jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 22, min: 14, max: 40, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 38, min: 22, max: 60, storageKey: "mail-list", children: [_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: setPosition, limit: PAGE, loading: threads.loading, now: now, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }), _jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward })] })] }), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), draft && (_jsx(Modal, { title: "New message", width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) }))] }));
174
+ } }) }), _jsx(Button, { kind: "ghost", onClick: () => setPaletteOpen(true), "aria-label": "Open the palette", children: _jsx(Icon, { name: "bolt" }) }), account] }), _jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 22, min: 14, max: 40, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 38, min: 22, max: 60, storageKey: "mail-list", children: [_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: setPosition, limit: PAGE, loading: threads.loading, now: now, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }), _jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward })] })] }), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), draft && (_jsx(Modal, { title: "New message", width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) }))] }));
149
175
  }
@@ -21,4 +21,4 @@ export type { ThreadViewProps } from './ThreadView.tsx';
21
21
  export { Composer, parseAddresses } from './Composer.tsx';
22
22
  export type { ComposerProps } from './Composer.tsx';
23
23
  export { Mail } from './Mail.tsx';
24
- export type { MailProps } from './Mail.tsx';
24
+ export type { MailLocation, MailProps } from './Mail.tsx';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
5
5
  "license": "MIT",
6
6
  "type": "module",