@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.
- package/package.json +1 -1
- package/src/components/mobile-search-view.render.test.ts +5 -8
- package/src/components/mobile-search-view.stories.tsx +20 -11
- package/src/components/mobile-search-view.tsx +0 -8
- package/src/components/search-results.render.test.ts +86 -21
- package/src/components/search-results.stories.tsx +24 -10
- package/src/components/search-results.tsx +39 -30
- 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/spam-results-offer.render.test.ts +1 -1
- package/src/components/spam-results-offer.tsx +7 -1
- package/src/index.ts +32 -0
|
@@ -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
|
+
}
|