@remit/ui 0.0.20 → 0.0.22

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.
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { demoLogsCommand, demoRelease } from "./self-update.js";
3
+ import {
4
+ SelfUpdateProgressOverlay,
5
+ SelfUpdateUnreachableScreen,
6
+ } from "./self-update-progress-overlay.js";
7
+
8
+ const meta: Meta<typeof SelfUpdateProgressOverlay> = {
9
+ title: "Settings/Self-update restart",
10
+ component: SelfUpdateProgressOverlay,
11
+ parameters: { layout: "fullscreen" },
12
+ args: { target: demoRelease.version },
13
+ decorators: [
14
+ (Story) => (
15
+ <div className="h-dvh w-full bg-canvas p-6">
16
+ <p className="text-sm text-fg-subtle">
17
+ Settings sits here. The overlay is fixed to the window, so this stays
18
+ covered and out of tab order.
19
+ </p>
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj<typeof SelfUpdateProgressOverlay>;
28
+
29
+ /**
30
+ * The new version is being put in place; the running server has not gone away
31
+ * yet.
32
+ */
33
+ export const Preparing: Story = {
34
+ args: { phase: "preparing", elapsedSeconds: 6 },
35
+ };
36
+
37
+ /** The server is going down. From here the page has nothing to talk to. */
38
+ export const Restarting: Story = {
39
+ args: { phase: "restarting", elapsedSeconds: 24 },
40
+ };
41
+
42
+ /**
43
+ * Polling for a server that is not answering yet. This is the normal middle of
44
+ * an update, so it reads as waiting, not as failure.
45
+ */
46
+ export const Reconnecting: Story = {
47
+ args: { phase: "reconnecting", elapsedSeconds: 48 },
48
+ };
49
+
50
+ /**
51
+ * Past the point where "about a minute" is still true. The copy stops making
52
+ * that promise rather than repeating it.
53
+ */
54
+ export const ReconnectingTakingLong: Story = {
55
+ args: { phase: "reconnecting", elapsedSeconds: 200 },
56
+ };
57
+
58
+ /**
59
+ * Long silence. The copy still describes only what the client can observe —
60
+ * how long it has been quiet — and never what the server is doing about it.
61
+ */
62
+ export const ReconnectingStillSilent: Story = {
63
+ args: { phase: "reconnecting", elapsedSeconds: 300 },
64
+ };
65
+
66
+ /**
67
+ * The server never came back. The client cannot see the rollback from here, so
68
+ * it says what it knows and points at the machine that can answer.
69
+ */
70
+ export const NeverCameBack: StoryObj<typeof SelfUpdateUnreachableScreen> = {
71
+ render: () => (
72
+ <SelfUpdateUnreachableScreen
73
+ attemptedVersion={demoRelease.version}
74
+ previousVersion="0.9.3"
75
+ elapsedSeconds={420}
76
+ logsCommand={demoLogsCommand}
77
+ onRetryConnection={() => {}}
78
+ />
79
+ ),
80
+ };
@@ -0,0 +1,203 @@
1
+ import { AlertOctagon, Check, Loader2 } from "lucide-react";
2
+ import { type RefObject, useEffect, useRef } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Button } from "./button.js";
5
+ import {
6
+ type UpdatePhase,
7
+ updatePhaseLabel,
8
+ updateWaitNote,
9
+ } from "./self-update.js";
10
+
11
+ const phaseOrder: UpdatePhase[] = ["preparing", "restarting", "reconnecting"];
12
+
13
+ const FOCUSABLE =
14
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
15
+
16
+ /**
17
+ * A screen whose purpose is to stop interaction has to stop it for the
18
+ * keyboard too. Focus moves in on mount and Tab cycles inside, so nothing
19
+ * behind the overlay can be reached while the server is gone.
20
+ */
21
+ function useBlockingFocus(ref: RefObject<HTMLDivElement | null>) {
22
+ useEffect(() => {
23
+ const container = ref.current;
24
+ if (!container) return;
25
+
26
+ const focusables = () =>
27
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE));
28
+
29
+ (focusables()[0] ?? container).focus();
30
+
31
+ const onKeyDown = (event: KeyboardEvent) => {
32
+ if (event.key !== "Tab") return;
33
+ const items = focusables();
34
+ if (items.length === 0) {
35
+ event.preventDefault();
36
+ container.focus();
37
+ return;
38
+ }
39
+ const first = items[0];
40
+ const last = items[items.length - 1];
41
+ const active = document.activeElement;
42
+ if (event.shiftKey && (active === first || !container.contains(active))) {
43
+ event.preventDefault();
44
+ last.focus();
45
+ return;
46
+ }
47
+ if (!event.shiftKey && (active === last || !container.contains(active))) {
48
+ event.preventDefault();
49
+ first.focus();
50
+ }
51
+ };
52
+
53
+ window.addEventListener("keydown", onKeyDown, true);
54
+ return () => window.removeEventListener("keydown", onKeyDown, true);
55
+ }, [ref]);
56
+ }
57
+
58
+ const overlayShell =
59
+ "fixed inset-0 z-50 flex flex-col items-center justify-center gap-6 bg-canvas p-6 text-center outline-none";
60
+
61
+ export interface SelfUpdateProgressOverlayProps {
62
+ target: string;
63
+ phase: UpdatePhase;
64
+ elapsedSeconds: number;
65
+ }
66
+
67
+ /**
68
+ * Applying an update takes the server away, so the app genuinely cannot be
69
+ * used while it runs. Blocking here is the honest state — a background spinner
70
+ * over a dead mailbox would let a broken system look healthy.
71
+ */
72
+ export function SelfUpdateProgressOverlay({
73
+ target,
74
+ phase,
75
+ elapsedSeconds,
76
+ }: SelfUpdateProgressOverlayProps) {
77
+ const ref = useRef<HTMLDivElement>(null);
78
+ useBlockingFocus(ref);
79
+ const activeIndex = phaseOrder.indexOf(phase);
80
+
81
+ return (
82
+ <div
83
+ ref={ref}
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-labelledby="self-update-progress-title"
87
+ tabIndex={-1}
88
+ className={overlayShell}
89
+ >
90
+ <div className="max-w-md space-y-2">
91
+ <h1
92
+ id="self-update-progress-title"
93
+ className="text-lg font-semibold text-fg"
94
+ >
95
+ Installing Remit {target}
96
+ </h1>
97
+ <p className="text-sm text-fg-muted">
98
+ Remit is restarting, so this page has no server to talk to for a
99
+ moment. Leave this open — it reconnects on its own.
100
+ </p>
101
+ </div>
102
+
103
+ <ol className="w-full max-w-sm space-y-2 text-left">
104
+ {phaseOrder.map((step, index) => {
105
+ const done = index < activeIndex;
106
+ const active = index === activeIndex;
107
+ return (
108
+ <li
109
+ key={step}
110
+ className={cn(
111
+ "flex items-center gap-3 rounded-sm border px-row-inset py-2 text-sm",
112
+ active
113
+ ? "border-line-strong bg-surface text-fg"
114
+ : "border-line bg-surface-sunken text-fg-subtle",
115
+ )}
116
+ >
117
+ {done ? (
118
+ <Check className="size-4 shrink-0 text-positive" aria-hidden />
119
+ ) : active ? (
120
+ <Loader2
121
+ className="size-4 shrink-0 animate-spin text-accent-2"
122
+ aria-hidden
123
+ />
124
+ ) : (
125
+ <span className="size-4 shrink-0 rounded-full border border-line-strong" />
126
+ )}
127
+ {updatePhaseLabel(step)}
128
+ </li>
129
+ );
130
+ })}
131
+ </ol>
132
+
133
+ <p aria-live="polite" className="max-w-md text-xs text-fg-subtle">
134
+ <span className="sr-only">{`${updatePhaseLabel(phase)}. `}</span>
135
+ {updateWaitNote(elapsedSeconds)}
136
+ </p>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export interface SelfUpdateUnreachableScreenProps {
142
+ attemptedVersion: string;
143
+ previousVersion: string;
144
+ elapsedSeconds: number;
145
+ logsCommand: string;
146
+ onRetryConnection: () => void;
147
+ }
148
+
149
+ /**
150
+ * The server never came back. The client cannot tell whether the rollback ran,
151
+ * so it must not claim that it did — it says what it knows, what it does not,
152
+ * and where to look on the machine that is still running.
153
+ */
154
+ export function SelfUpdateUnreachableScreen({
155
+ attemptedVersion,
156
+ previousVersion,
157
+ elapsedSeconds,
158
+ logsCommand,
159
+ onRetryConnection,
160
+ }: SelfUpdateUnreachableScreenProps) {
161
+ const ref = useRef<HTMLDivElement>(null);
162
+ useBlockingFocus(ref);
163
+ const minutes = Math.max(1, Math.round(elapsedSeconds / 60));
164
+
165
+ return (
166
+ <div
167
+ ref={ref}
168
+ role="alertdialog"
169
+ aria-modal="true"
170
+ aria-labelledby="self-update-unreachable-title"
171
+ tabIndex={-1}
172
+ className={overlayShell}
173
+ >
174
+ <AlertOctagon className="size-12 shrink-0 text-danger" aria-hidden />
175
+ <div className="max-w-lg space-y-3">
176
+ <h1
177
+ id="self-update-unreachable-title"
178
+ className="text-lg font-semibold text-fg"
179
+ >
180
+ Remit has not answered since the restart
181
+ </h1>
182
+ <p className="text-sm text-fg-muted">
183
+ Installing {attemptedVersion} started {minutes} minute
184
+ {minutes === 1 ? "" : "s"} ago and the server has been silent since.
185
+ Your mail is safe at your provider — it was never part of the update.
186
+ </p>
187
+ <p className="text-sm text-fg-muted">
188
+ Remit rolls back to {previousVersion} on its own when the new version
189
+ fails to start, but this page cannot confirm that from here. Check the
190
+ server directly:
191
+ </p>
192
+ <code className="block rounded-xs bg-surface-sunken px-3 py-2 text-left text-xs text-fg-muted">
193
+ {logsCommand}
194
+ </code>
195
+ </div>
196
+ <div className="flex flex-wrap items-center justify-center gap-2">
197
+ <Button size="sm" onClick={onRetryConnection}>
198
+ Try connecting again
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,377 @@
1
+ import {
2
+ CheckCircle2,
3
+ CloudOff,
4
+ Download,
5
+ ExternalLink,
6
+ Loader2,
7
+ RotateCcw,
8
+ TriangleAlert,
9
+ } from "lucide-react";
10
+ import { type ReactNode, useState } from "react";
11
+ import { cn } from "../lib/cn.js";
12
+ import { Badge } from "./badge.js";
13
+ import { Banner } from "./banner.js";
14
+ import { Button, ButtonLink } from "./button.js";
15
+ import {
16
+ formatRelativeCheck,
17
+ formatReleaseDate,
18
+ type SelfUpdateState,
19
+ } from "./self-update.js";
20
+
21
+ export interface SelfUpdateSectionProps {
22
+ state: SelfUpdateState;
23
+ onCheck: () => void;
24
+ /** Opens consent before anything is replaced. */
25
+ onInstall: () => void;
26
+ /**
27
+ * Clears a finished result from the pane. Required: it is the only exit
28
+ * from `succeeded` and `rolledBack`, and without it the pane sticks on a
29
+ * red failure row for good.
30
+ */
31
+ onDismissResult: () => void;
32
+ /** Fixed "now" so stories and tests read the same relative times. */
33
+ now?: number;
34
+ }
35
+
36
+ function SectionRow({
37
+ children,
38
+ tone,
39
+ }: {
40
+ children: ReactNode;
41
+ tone?: "danger";
42
+ }) {
43
+ return (
44
+ <div
45
+ className={cn(
46
+ "rounded-sm border bg-surface px-row-inset py-3",
47
+ tone === "danger" ? "border-danger/50" : "border-line",
48
+ )}
49
+ >
50
+ {children}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Updates, in Settings › Advanced.
57
+ *
58
+ * A mail client is read first and administered second, so an available update
59
+ * is stated here and nowhere else — no modal, no interruption, no repeat
60
+ * asking. Applying one is consequential (the server goes away and comes back),
61
+ * so it is never one click from this pane.
62
+ */
63
+ export function SelfUpdateSection({
64
+ state,
65
+ onCheck,
66
+ onInstall,
67
+ onDismissResult,
68
+ now = Date.now(),
69
+ }: SelfUpdateSectionProps) {
70
+ const [notice, setNotice] = useState<string | null>(null);
71
+
72
+ const checking = state.status === "checking";
73
+ const installable = state.status === "available";
74
+
75
+ const handleCheck = () => {
76
+ if (checking) {
77
+ setNotice("Already checking. The result appears here in a moment.");
78
+ return;
79
+ }
80
+ setNotice(null);
81
+ onCheck();
82
+ };
83
+
84
+ const handleInstall = () => {
85
+ if (!installable && state.status !== "rolledBack") {
86
+ setNotice(
87
+ "There is no update to install. Check for updates first — if one is found it appears here.",
88
+ );
89
+ return;
90
+ }
91
+ setNotice(null);
92
+ onInstall();
93
+ };
94
+
95
+ const body = ((): ReactNode => {
96
+ switch (state.status) {
97
+ case "upToDate":
98
+ return (
99
+ <SectionRow>
100
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
101
+ <div className="flex min-w-0 items-center gap-2">
102
+ <CheckCircle2
103
+ className="size-4 shrink-0 text-positive"
104
+ aria-hidden
105
+ />
106
+ <p className="text-sm text-fg">
107
+ Remit {state.version} is the latest version.
108
+ <span className="text-fg-subtle">
109
+ {" "}
110
+ Checked {formatRelativeCheck(state.checkedAt, now)}.
111
+ </span>
112
+ </p>
113
+ </div>
114
+ <Button
115
+ variant="secondary"
116
+ size="sm"
117
+ className="shrink-0"
118
+ onClick={handleCheck}
119
+ >
120
+ Check again
121
+ </Button>
122
+ </div>
123
+ </SectionRow>
124
+ );
125
+
126
+ case "checking":
127
+ return (
128
+ <SectionRow>
129
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
130
+ <div className="flex min-w-0 items-center gap-2">
131
+ <Loader2
132
+ className="size-4 shrink-0 animate-spin text-fg-subtle"
133
+ aria-hidden
134
+ />
135
+ <p className="text-sm text-fg-muted">
136
+ Looking for a newer version. You are on {state.version}.
137
+ </p>
138
+ </div>
139
+ <Button
140
+ variant="secondary"
141
+ size="sm"
142
+ className="shrink-0"
143
+ onClick={handleCheck}
144
+ >
145
+ Check again
146
+ </Button>
147
+ </div>
148
+ </SectionRow>
149
+ );
150
+
151
+ case "checkFailed":
152
+ return (
153
+ <SectionRow>
154
+ <div className="space-y-2">
155
+ <div className="flex items-start gap-2">
156
+ <CloudOff
157
+ className="mt-0.5 size-4 shrink-0 text-fg-subtle"
158
+ aria-hidden
159
+ />
160
+ <div className="min-w-0 space-y-1">
161
+ <p className="text-sm text-fg">
162
+ Could not reach the update source.
163
+ </p>
164
+ <p className="text-xs text-fg-muted">{state.reason}</p>
165
+ <p className="text-xs text-fg-subtle">
166
+ You are still on {state.version} and it keeps working.
167
+ {state.lastCheckedAt !== undefined && (
168
+ <>
169
+ {" "}
170
+ Last successful check{" "}
171
+ {formatRelativeCheck(state.lastCheckedAt, now)}.
172
+ </>
173
+ )}
174
+ </p>
175
+ </div>
176
+ </div>
177
+ <div className="flex justify-end">
178
+ <Button variant="secondary" size="sm" onClick={handleCheck}>
179
+ Try again
180
+ </Button>
181
+ </div>
182
+ </div>
183
+ </SectionRow>
184
+ );
185
+
186
+ case "available":
187
+ return (
188
+ <SectionRow>
189
+ <div className="space-y-3">
190
+ <div className="flex flex-wrap items-center gap-2">
191
+ <span className="text-sm font-semibold text-fg">
192
+ Remit {state.release.version}
193
+ </span>
194
+ <Badge tone="accent">update available</Badge>
195
+ <span className="text-2xs text-fg-subtle">
196
+ released {formatReleaseDate(state.release.releasedAt)} · you
197
+ are on {state.version}
198
+ </span>
199
+ </div>
200
+ <p className="text-sm text-fg-muted">{state.release.summary}</p>
201
+ <p className="text-xs text-fg-subtle">
202
+ Installing restarts Remit, so mail stops loading for about a
203
+ minute. If the new version does not come back up, the one you
204
+ are running now is restored on its own.
205
+ </p>
206
+ <div className="flex flex-wrap items-center gap-2">
207
+ <Button
208
+ size="sm"
209
+ icon={<Download className="size-3.5" />}
210
+ onClick={handleInstall}
211
+ >
212
+ Install {state.release.version}
213
+ </Button>
214
+ <ButtonLink
215
+ variant="secondary"
216
+ size="sm"
217
+ external
218
+ href={state.release.releaseNotesUrl}
219
+ icon={<ExternalLink className="size-3.5" />}
220
+ >
221
+ Release notes
222
+ </ButtonLink>
223
+ </div>
224
+ </div>
225
+ </SectionRow>
226
+ );
227
+
228
+ case "applying":
229
+ return (
230
+ <SectionRow>
231
+ <div className="flex min-w-0 items-center gap-2">
232
+ <Loader2
233
+ className="size-4 shrink-0 animate-spin text-accent-2"
234
+ aria-hidden
235
+ />
236
+ <p className="text-sm text-fg-muted">
237
+ Installing Remit {state.target}. The restart screen has the
238
+ details.
239
+ </p>
240
+ </div>
241
+ </SectionRow>
242
+ );
243
+
244
+ case "succeeded":
245
+ return (
246
+ <Banner tone="success" onDismiss={onDismissResult}>
247
+ <div className="space-y-1">
248
+ <p className="font-semibold">Updated to Remit {state.version}.</p>
249
+ <p>
250
+ You were on {state.previousVersion}.{" "}
251
+ <a
252
+ href={state.releaseNotesUrl}
253
+ target="_blank"
254
+ rel="noopener noreferrer"
255
+ className="underline"
256
+ >
257
+ See what changed
258
+ </a>
259
+ .
260
+ </p>
261
+ </div>
262
+ </Banner>
263
+ );
264
+
265
+ case "rolledBack":
266
+ return (
267
+ <SectionRow tone="danger">
268
+ <div className="space-y-3">
269
+ <div className="flex items-start gap-2">
270
+ <TriangleAlert
271
+ className="mt-0.5 size-4 shrink-0 text-danger"
272
+ aria-hidden
273
+ />
274
+ <div className="min-w-0 space-y-1">
275
+ <p className="text-sm font-semibold text-fg">
276
+ Remit {state.attemptedVersion} did not start. Remit reports
277
+ that it put {state.version} back.
278
+ </p>
279
+ <p className="text-sm text-fg-muted">
280
+ You are running {state.version} again. A failed update can
281
+ still have changed things on the way — the log below is the
282
+ only account of what it got as far as doing.
283
+ </p>
284
+ </div>
285
+ </div>
286
+ <div className="space-y-1">
287
+ <p className="text-xs text-fg-subtle">
288
+ What Remit reported as the failure
289
+ </p>
290
+ <code className="block rounded-xs bg-danger-soft px-2 py-1 text-2xs text-danger">
291
+ {state.reason}
292
+ </code>
293
+ </div>
294
+ <div className="space-y-1">
295
+ <p className="text-xs text-fg-subtle">
296
+ Read the full log before trying again:
297
+ </p>
298
+ <code className="block rounded-xs bg-surface-sunken px-2 py-1 text-2xs text-fg-muted">
299
+ {state.logsCommand}
300
+ </code>
301
+ </div>
302
+ <div className="flex flex-wrap items-center gap-2">
303
+ <Button
304
+ variant="secondary"
305
+ size="sm"
306
+ icon={<RotateCcw className="size-3.5" />}
307
+ onClick={handleInstall}
308
+ >
309
+ Try {state.attemptedVersion} again
310
+ </Button>
311
+ <Button variant="ghost" size="sm" onClick={onDismissResult}>
312
+ Stay on {state.version}
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ </SectionRow>
317
+ );
318
+
319
+ case "unreachable":
320
+ return (
321
+ <SectionRow tone="danger">
322
+ <div className="space-y-2">
323
+ <div className="flex items-start gap-2">
324
+ <TriangleAlert
325
+ className="mt-0.5 size-4 shrink-0 text-danger"
326
+ aria-hidden
327
+ />
328
+ <div className="min-w-0 space-y-1">
329
+ <p className="text-sm font-semibold text-fg">
330
+ Installing {state.attemptedVersion} left the server
331
+ unreachable.
332
+ </p>
333
+ <p className="text-sm text-fg-muted">
334
+ Remit has answered again since, but nothing here can say
335
+ what happened during the silence.
336
+ </p>
337
+ </div>
338
+ </div>
339
+ <code className="block rounded-xs bg-surface-sunken px-2 py-1 text-2xs text-fg-muted">
340
+ {state.logsCommand}
341
+ </code>
342
+ <div className="flex justify-end">
343
+ <Button variant="ghost" size="sm" onClick={onDismissResult}>
344
+ Dismiss
345
+ </Button>
346
+ </div>
347
+ </div>
348
+ </SectionRow>
349
+ );
350
+
351
+ default: {
352
+ const exhaustive: never = state;
353
+ return exhaustive;
354
+ }
355
+ }
356
+ })();
357
+
358
+ return (
359
+ <section className="space-y-3">
360
+ <header className="space-y-1">
361
+ <h2 className="text-sm font-semibold text-fg">Updates</h2>
362
+ <p className="text-xs text-fg-muted">
363
+ This Remit runs on your own server, so it updates when you say so.
364
+ Your mail lives at your provider and is never touched by an update.
365
+ </p>
366
+ </header>
367
+
368
+ {body}
369
+
370
+ {notice && (
371
+ <p role="status" className="text-xs text-fg-muted">
372
+ {notice}
373
+ </p>
374
+ )}
375
+ </section>
376
+ );
377
+ }