@remit/ui 0.0.21 → 0.0.23
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.
- package/package.json +1 -1
- package/src/components/self-update-confirm-dialog.tsx +86 -0
- package/src/components/self-update-dot.tsx +32 -0
- package/src/components/self-update-progress-overlay.stories.tsx +80 -0
- package/src/components/self-update-progress-overlay.tsx +203 -0
- package/src/components/self-update-section.tsx +377 -0
- package/src/components/self-update.render.test.ts +294 -0
- package/src/components/self-update.stories.tsx +212 -0
- package/src/components/self-update.ts +127 -0
- package/src/components/swipeable-row.tsx +1 -1
- package/src/index.ts +32 -0
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Download, ExternalLink } from "lucide-react";
|
|
2
|
+
import { Button, ButtonLink } from "./button.js";
|
|
3
|
+
import { Dialog } from "./dialog.js";
|
|
4
|
+
import { formatReleaseDate, type ReleaseInfo } from "./self-update.js";
|
|
5
|
+
|
|
6
|
+
export interface SelfUpdateConfirmDialogProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
currentVersion: string;
|
|
9
|
+
release: ReleaseInfo;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const consequences = [
|
|
15
|
+
"Remit restarts. Mail stops loading for about a minute and this page will lose its connection while that happens.",
|
|
16
|
+
"Nothing is sent, deleted or moved. Your mail stays at your provider.",
|
|
17
|
+
"If the new version does not come back up, the version you are running now is restored automatically.",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Consent before the server replaces itself. Reflects what will happen in the
|
|
22
|
+
* user's terms, in the order they will feel it, and leaves the way back open
|
|
23
|
+
* until the moment they commit.
|
|
24
|
+
*/
|
|
25
|
+
export function SelfUpdateConfirmDialog({
|
|
26
|
+
open,
|
|
27
|
+
currentVersion,
|
|
28
|
+
release,
|
|
29
|
+
onClose,
|
|
30
|
+
onConfirm,
|
|
31
|
+
}: SelfUpdateConfirmDialogProps) {
|
|
32
|
+
if (!open) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Dialog open onClose={onClose} title={`Install Remit ${release.version}`}>
|
|
36
|
+
<div className="flex max-h-[80vh] flex-col">
|
|
37
|
+
<header className="space-y-1 border-b border-line px-4 py-3">
|
|
38
|
+
<h3 className="text-sm font-semibold text-fg">
|
|
39
|
+
Install Remit {release.version}?
|
|
40
|
+
</h3>
|
|
41
|
+
<p className="text-xs text-fg-muted">
|
|
42
|
+
You are on {currentVersion}. {release.version} was released{" "}
|
|
43
|
+
{formatReleaseDate(release.releasedAt)}.
|
|
44
|
+
</p>
|
|
45
|
+
</header>
|
|
46
|
+
|
|
47
|
+
<div className="flex-1 space-y-3 overflow-auto px-4 py-3">
|
|
48
|
+
<p className="text-sm text-fg-muted">{release.summary}</p>
|
|
49
|
+
<ul className="space-y-2">
|
|
50
|
+
{consequences.map((line) => (
|
|
51
|
+
<li key={line} className="flex gap-2 text-sm text-fg-muted">
|
|
52
|
+
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-subtle" />
|
|
53
|
+
<span>{line}</span>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
<p className="text-xs text-fg-subtle">
|
|
58
|
+
Good moment for this: when you are not waiting on a message.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line px-4 py-3">
|
|
63
|
+
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
64
|
+
Not now
|
|
65
|
+
</Button>
|
|
66
|
+
<ButtonLink
|
|
67
|
+
variant="secondary"
|
|
68
|
+
size="sm"
|
|
69
|
+
external
|
|
70
|
+
href={release.releaseNotesUrl}
|
|
71
|
+
icon={<ExternalLink className="size-3.5" />}
|
|
72
|
+
>
|
|
73
|
+
Release notes
|
|
74
|
+
</ButtonLink>
|
|
75
|
+
<Button
|
|
76
|
+
size="sm"
|
|
77
|
+
icon={<Download className="size-3.5" />}
|
|
78
|
+
onClick={onConfirm}
|
|
79
|
+
>
|
|
80
|
+
Install and restart
|
|
81
|
+
</Button>
|
|
82
|
+
</footer>
|
|
83
|
+
</div>
|
|
84
|
+
</Dialog>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UpdateAvailableDotProps {
|
|
4
|
+
/** The icon or control the hint is attached to. */
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
show: boolean;
|
|
7
|
+
/** Read out to assistive tech in place of the bare dot. */
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The only place an available update is allowed to reach outside settings: a
|
|
13
|
+
* dot on the way there. It carries no count, no copy and no action, so it can
|
|
14
|
+
* never grow into a prompt over someone's mail.
|
|
15
|
+
*/
|
|
16
|
+
export function UpdateAvailableDot({
|
|
17
|
+
children,
|
|
18
|
+
show,
|
|
19
|
+
label = "Update available",
|
|
20
|
+
}: UpdateAvailableDotProps) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="relative inline-flex">
|
|
23
|
+
{children}
|
|
24
|
+
{show && (
|
|
25
|
+
<>
|
|
26
|
+
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-accent-2" />
|
|
27
|
+
<span className="sr-only">{label}</span>
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|