@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,294 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
demoLogsCommand,
|
|
7
|
+
demoRelease,
|
|
8
|
+
demoRunId,
|
|
9
|
+
type SelfUpdateState,
|
|
10
|
+
updateWaitNote,
|
|
11
|
+
} from "./self-update.js";
|
|
12
|
+
import { SelfUpdateConfirmDialog } from "./self-update-confirm-dialog.js";
|
|
13
|
+
import { UpdateAvailableDot } from "./self-update-dot.js";
|
|
14
|
+
import {
|
|
15
|
+
SelfUpdateProgressOverlay,
|
|
16
|
+
SelfUpdateUnreachableScreen,
|
|
17
|
+
} from "./self-update-progress-overlay.js";
|
|
18
|
+
import { SelfUpdateSection } from "./self-update-section.js";
|
|
19
|
+
|
|
20
|
+
const noop = () => {};
|
|
21
|
+
const NOW = Date.parse("2026-07-20T12:00:00.000Z");
|
|
22
|
+
const CURRENT = "0.9.3";
|
|
23
|
+
|
|
24
|
+
/** SSR splits interpolations with comment markers; sentences read across them. */
|
|
25
|
+
const render = (element: Parameters<typeof renderToString>[0]) =>
|
|
26
|
+
renderToString(element).replaceAll("<!-- -->", "");
|
|
27
|
+
|
|
28
|
+
const section = (state: SelfUpdateState) =>
|
|
29
|
+
render(
|
|
30
|
+
createElement(SelfUpdateSection, {
|
|
31
|
+
state,
|
|
32
|
+
now: NOW,
|
|
33
|
+
onCheck: noop,
|
|
34
|
+
onInstall: noop,
|
|
35
|
+
onDismissResult: noop,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe("SelfUpdateSection", () => {
|
|
40
|
+
it("states being up to date without raising an alert", () => {
|
|
41
|
+
const html = section({
|
|
42
|
+
status: "upToDate",
|
|
43
|
+
version: CURRENT,
|
|
44
|
+
checkedAt: NOW - 60_000,
|
|
45
|
+
});
|
|
46
|
+
assert.match(html, /0\.9\.3 is the latest version/);
|
|
47
|
+
assert.doesNotMatch(html, /role="alert"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("offers an available update without shouting about it", () => {
|
|
51
|
+
const html = section({
|
|
52
|
+
status: "available",
|
|
53
|
+
version: CURRENT,
|
|
54
|
+
release: demoRelease,
|
|
55
|
+
});
|
|
56
|
+
assert.match(html, /Install 0\.9\.4/);
|
|
57
|
+
assert.match(html, /Release notes/);
|
|
58
|
+
assert.doesNotMatch(html, /role="alert"/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("names the automatic rollback before the user commits", () => {
|
|
62
|
+
const html = section({
|
|
63
|
+
status: "available",
|
|
64
|
+
version: CURRENT,
|
|
65
|
+
release: demoRelease,
|
|
66
|
+
});
|
|
67
|
+
assert.match(html, /restored on its own/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps a failed check separate from a failed update", () => {
|
|
71
|
+
const html = section({
|
|
72
|
+
status: "checkFailed",
|
|
73
|
+
version: CURRENT,
|
|
74
|
+
reason: "No route to github.com",
|
|
75
|
+
lastCheckedAt: NOW - 3_600_000,
|
|
76
|
+
});
|
|
77
|
+
assert.match(html, /Could not reach the update source/);
|
|
78
|
+
assert.match(html, /still on 0\.9\.3 and it keeps working/);
|
|
79
|
+
assert.match(html, /Try again/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("says which version is running after a rollback, and where the log is", () => {
|
|
83
|
+
const html = section({
|
|
84
|
+
status: "rolledBack",
|
|
85
|
+
runId: demoRunId,
|
|
86
|
+
version: CURRENT,
|
|
87
|
+
attemptedVersion: "0.9.4",
|
|
88
|
+
reason: "migration 0042 failed",
|
|
89
|
+
logsCommand: demoLogsCommand,
|
|
90
|
+
});
|
|
91
|
+
assert.match(html, /running 0\.9\.3 again/);
|
|
92
|
+
assert.match(html, /migration 0042 failed/);
|
|
93
|
+
assert.match(html, /remit logs --since 10m/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("attributes the rollback to the server rather than asserting it", () => {
|
|
97
|
+
const html = section({
|
|
98
|
+
status: "rolledBack",
|
|
99
|
+
runId: demoRunId,
|
|
100
|
+
version: CURRENT,
|
|
101
|
+
attemptedVersion: "0.9.4",
|
|
102
|
+
reason: "migration 0042 failed",
|
|
103
|
+
logsCommand: demoLogsCommand,
|
|
104
|
+
});
|
|
105
|
+
assert.match(html, /Remit reports that it put 0\.9\.3 back/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("never claims data survived a failed update", () => {
|
|
109
|
+
const html = section({
|
|
110
|
+
status: "rolledBack",
|
|
111
|
+
runId: demoRunId,
|
|
112
|
+
version: CURRENT,
|
|
113
|
+
attemptedVersion: "0.9.4",
|
|
114
|
+
reason: "migration 0042_add_thread_index failed",
|
|
115
|
+
logsCommand: demoLogsCommand,
|
|
116
|
+
});
|
|
117
|
+
// The server reported a rollback and a reason. It reported nothing about
|
|
118
|
+
// data, and a half-applied migration is exactly where that matters.
|
|
119
|
+
assert.doesNotMatch(html, /Nothing was lost/);
|
|
120
|
+
assert.doesNotMatch(html, /everything works/);
|
|
121
|
+
assert.match(html, /can still have changed things/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("renders every status rather than going blank", () => {
|
|
125
|
+
assert.match(
|
|
126
|
+
section({
|
|
127
|
+
status: "applying",
|
|
128
|
+
runId: demoRunId,
|
|
129
|
+
version: CURRENT,
|
|
130
|
+
target: "0.9.4",
|
|
131
|
+
phase: "restarting",
|
|
132
|
+
elapsedSeconds: 20,
|
|
133
|
+
}),
|
|
134
|
+
/Installing Remit 0\.9\.4/,
|
|
135
|
+
);
|
|
136
|
+
assert.match(
|
|
137
|
+
section({
|
|
138
|
+
status: "unreachable",
|
|
139
|
+
runId: demoRunId,
|
|
140
|
+
previousVersion: CURRENT,
|
|
141
|
+
attemptedVersion: "0.9.4",
|
|
142
|
+
elapsedSeconds: 420,
|
|
143
|
+
logsCommand: demoLogsCommand,
|
|
144
|
+
}),
|
|
145
|
+
/left the server unreachable/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("offers a way forward from every failure", () => {
|
|
150
|
+
const rolledBack = section({
|
|
151
|
+
status: "rolledBack",
|
|
152
|
+
runId: demoRunId,
|
|
153
|
+
version: CURRENT,
|
|
154
|
+
attemptedVersion: "0.9.4",
|
|
155
|
+
reason: "boom",
|
|
156
|
+
logsCommand: demoLogsCommand,
|
|
157
|
+
});
|
|
158
|
+
assert.match(rolledBack, /Try 0\.9\.4 again/);
|
|
159
|
+
assert.match(rolledBack, /Stay on 0\.9\.3/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps the check control pressable while a check is running", () => {
|
|
163
|
+
const html = section({ status: "checking", version: CURRENT });
|
|
164
|
+
assert.match(html, /Check again/);
|
|
165
|
+
assert.doesNotMatch(html, /disabled=/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("SelfUpdateConfirmDialog", () => {
|
|
170
|
+
const html = render(
|
|
171
|
+
createElement(SelfUpdateConfirmDialog, {
|
|
172
|
+
open: true,
|
|
173
|
+
currentVersion: CURRENT,
|
|
174
|
+
release: demoRelease,
|
|
175
|
+
onClose: noop,
|
|
176
|
+
onConfirm: noop,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
it("reflects the consequence in the order the user will feel it", () => {
|
|
181
|
+
assert.match(html, /lose its connection/);
|
|
182
|
+
assert.match(html, /stays at your provider/);
|
|
183
|
+
assert.match(html, /restored automatically/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("always offers the way back", () => {
|
|
187
|
+
assert.match(html, /Not now/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders nothing when closed", () => {
|
|
191
|
+
assert.equal(
|
|
192
|
+
renderToString(
|
|
193
|
+
createElement(SelfUpdateConfirmDialog, {
|
|
194
|
+
open: false,
|
|
195
|
+
currentVersion: CURRENT,
|
|
196
|
+
release: demoRelease,
|
|
197
|
+
onClose: noop,
|
|
198
|
+
onConfirm: noop,
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
"",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("SelfUpdateProgressOverlay", () => {
|
|
207
|
+
it("tells the user the disconnection is expected", () => {
|
|
208
|
+
const html = render(
|
|
209
|
+
createElement(SelfUpdateProgressOverlay, {
|
|
210
|
+
target: "0.9.4",
|
|
211
|
+
phase: "restarting",
|
|
212
|
+
elapsedSeconds: 20,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
assert.match(html, /no server to talk to/);
|
|
216
|
+
assert.match(html, /Waiting for Remit to answer again/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("stops promising a minute once a minute has passed", () => {
|
|
220
|
+
assert.match(updateWaitNote(30), /about a minute/);
|
|
221
|
+
assert.doesNotMatch(updateWaitNote(200), /about a minute/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("never describes the server from a lost connection", () => {
|
|
225
|
+
// The unreachable screen refuses to claim the rollback ran; the timeout
|
|
226
|
+
// copy sits on the same dead connection and may not claim it either.
|
|
227
|
+
for (const seconds of [30, 120, 240, 600]) {
|
|
228
|
+
assert.doesNotMatch(updateWaitNote(seconds), /old version|way back|kept/);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("blocks the window rather than its nearest positioned ancestor", () => {
|
|
233
|
+
const html = render(
|
|
234
|
+
createElement(SelfUpdateProgressOverlay, {
|
|
235
|
+
target: "0.9.4",
|
|
236
|
+
phase: "restarting",
|
|
237
|
+
elapsedSeconds: 20,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
assert.match(html, /fixed inset-0/);
|
|
241
|
+
assert.doesNotMatch(html, /absolute inset-0/);
|
|
242
|
+
assert.match(html, /aria-modal="true"/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("scopes the live region to the line that changes", () => {
|
|
246
|
+
const html = render(
|
|
247
|
+
createElement(SelfUpdateProgressOverlay, {
|
|
248
|
+
target: "0.9.4",
|
|
249
|
+
phase: "restarting",
|
|
250
|
+
elapsedSeconds: 20,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
assert.equal(html.match(/aria-live/g)?.length, 1);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("SelfUpdateUnreachableScreen", () => {
|
|
258
|
+
const html = render(
|
|
259
|
+
createElement(SelfUpdateUnreachableScreen, {
|
|
260
|
+
attemptedVersion: "0.9.4",
|
|
261
|
+
previousVersion: CURRENT,
|
|
262
|
+
elapsedSeconds: 420,
|
|
263
|
+
logsCommand: demoLogsCommand,
|
|
264
|
+
onRetryConnection: noop,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
it("raises an alert and never claims the rollback succeeded", () => {
|
|
269
|
+
assert.match(html, /role="alertdialog"/);
|
|
270
|
+
assert.match(html, /aria-modal="true"/);
|
|
271
|
+
assert.match(html, /cannot confirm that from here/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("points at the machine that can answer", () => {
|
|
275
|
+
assert.match(html, /remit logs --since 10m/);
|
|
276
|
+
assert.match(html, /Try connecting again/);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("UpdateAvailableDot", () => {
|
|
281
|
+
it("carries a label for assistive tech when shown", () => {
|
|
282
|
+
const html = renderToString(
|
|
283
|
+
createElement(UpdateAvailableDot, { show: true }, null),
|
|
284
|
+
);
|
|
285
|
+
assert.match(html, /Update available/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("adds nothing when there is no update", () => {
|
|
289
|
+
const html = renderToString(
|
|
290
|
+
createElement(UpdateAvailableDot, { show: false }, null),
|
|
291
|
+
);
|
|
292
|
+
assert.doesNotMatch(html, /Update available/);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
demoLogsCommand,
|
|
5
|
+
demoRelease,
|
|
6
|
+
demoRunId,
|
|
7
|
+
type SelfUpdateState,
|
|
8
|
+
} from "./self-update.js";
|
|
9
|
+
import { SelfUpdateConfirmDialog } from "./self-update-confirm-dialog.js";
|
|
10
|
+
import { SelfUpdateSection } from "./self-update-section.js";
|
|
11
|
+
|
|
12
|
+
const NOW = Date.parse("2026-07-20T12:00:00.000Z");
|
|
13
|
+
const CURRENT = "0.9.3";
|
|
14
|
+
|
|
15
|
+
const meta: Meta<typeof SelfUpdateSection> = {
|
|
16
|
+
title: "Settings/Self-update",
|
|
17
|
+
component: SelfUpdateSection,
|
|
18
|
+
parameters: { layout: "padded" },
|
|
19
|
+
args: {
|
|
20
|
+
now: NOW,
|
|
21
|
+
onCheck: () => {},
|
|
22
|
+
onInstall: () => {},
|
|
23
|
+
onDismissResult: () => {},
|
|
24
|
+
},
|
|
25
|
+
decorators: [
|
|
26
|
+
(Story) => (
|
|
27
|
+
<div className="mx-auto max-w-2xl">
|
|
28
|
+
<Story />
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
export default meta;
|
|
34
|
+
|
|
35
|
+
type Story = StoryObj<typeof SelfUpdateSection>;
|
|
36
|
+
|
|
37
|
+
const withState = (state: SelfUpdateState) => ({ state });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The state this pane is in almost always. One line, no call to action, no
|
|
41
|
+
* colour — running the latest version is not news.
|
|
42
|
+
*/
|
|
43
|
+
export const UpToDate: Story = {
|
|
44
|
+
args: withState({
|
|
45
|
+
status: "upToDate",
|
|
46
|
+
version: CURRENT,
|
|
47
|
+
checkedAt: NOW - 21 * 60_000,
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Checking: Story = {
|
|
52
|
+
args: withState({ status: "checking", version: CURRENT }),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An update exists. It is stated — version, date, one line of what changes, a
|
|
57
|
+
* link to the full notes — and then it waits. Nothing here follows the user
|
|
58
|
+
* back to their mail.
|
|
59
|
+
*/
|
|
60
|
+
export const UpdateAvailable: Story = {
|
|
61
|
+
args: withState({
|
|
62
|
+
status: "available",
|
|
63
|
+
version: CURRENT,
|
|
64
|
+
release: demoRelease,
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Cannot reach the update source. This is not a failure of the running Remit
|
|
70
|
+
* and does not dress itself up as one: it names the cause, says the installed
|
|
71
|
+
* version keeps working, and offers the retry.
|
|
72
|
+
*/
|
|
73
|
+
export const CheckFailedOffline: Story = {
|
|
74
|
+
args: withState({
|
|
75
|
+
status: "checkFailed",
|
|
76
|
+
version: CURRENT,
|
|
77
|
+
reason: "No route to github.com — the server has no outbound network.",
|
|
78
|
+
lastCheckedAt: NOW - 3 * 24 * 60 * 60_000,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Back on a reachable server, on the new version. Dismissible, and gone for
|
|
84
|
+
* good once dismissed.
|
|
85
|
+
*/
|
|
86
|
+
export const Succeeded: Story = {
|
|
87
|
+
args: withState({
|
|
88
|
+
status: "succeeded",
|
|
89
|
+
runId: demoRunId,
|
|
90
|
+
version: demoRelease.version,
|
|
91
|
+
previousVersion: CURRENT,
|
|
92
|
+
releaseNotesUrl: demoRelease.releaseNotesUrl,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The new version did not start and Remit reports having put the old one back.
|
|
98
|
+
* The pane repeats that report and no more: a failed migration is exactly the
|
|
99
|
+
* case where something was changed on the way, so "nothing was lost" is not a
|
|
100
|
+
* claim this screen is in any position to make.
|
|
101
|
+
*/
|
|
102
|
+
export const RolledBack: Story = {
|
|
103
|
+
args: withState({
|
|
104
|
+
status: "rolledBack",
|
|
105
|
+
runId: demoRunId,
|
|
106
|
+
version: CURRENT,
|
|
107
|
+
attemptedVersion: demoRelease.version,
|
|
108
|
+
reason:
|
|
109
|
+
'migration 0042_add_thread_index failed: relation "threads" does not exist',
|
|
110
|
+
logsCommand: demoLogsCommand,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* An update is running. The blocking screen owns the window; the pane behind it
|
|
116
|
+
* still says what is going on rather than going blank.
|
|
117
|
+
*/
|
|
118
|
+
export const ApplyingBehindTheOverlay: Story = {
|
|
119
|
+
args: withState({
|
|
120
|
+
status: "applying",
|
|
121
|
+
runId: demoRunId,
|
|
122
|
+
version: CURRENT,
|
|
123
|
+
target: demoRelease.version,
|
|
124
|
+
phase: "restarting",
|
|
125
|
+
elapsedSeconds: 30,
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The server answered again after a silence the client could not see into. It
|
|
131
|
+
* says exactly that, and points at the log rather than guessing.
|
|
132
|
+
*/
|
|
133
|
+
export const RecoveredAfterUnreachable: Story = {
|
|
134
|
+
args: withState({
|
|
135
|
+
status: "unreachable",
|
|
136
|
+
runId: demoRunId,
|
|
137
|
+
previousVersion: CURRENT,
|
|
138
|
+
attemptedVersion: demoRelease.version,
|
|
139
|
+
elapsedSeconds: 420,
|
|
140
|
+
logsCommand: demoLogsCommand,
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Consent. Reflects the three things the user will actually feel — the pause,
|
|
146
|
+
* that mail is untouched, and the automatic way back — before anything is
|
|
147
|
+
* replaced. "Not now" is always the easiest button to hit.
|
|
148
|
+
*/
|
|
149
|
+
export const ConfirmBeforeInstalling: Story = {
|
|
150
|
+
render: () => (
|
|
151
|
+
<SelfUpdateConfirmDialog
|
|
152
|
+
open
|
|
153
|
+
currentVersion={CURRENT}
|
|
154
|
+
release={demoRelease}
|
|
155
|
+
onClose={() => {}}
|
|
156
|
+
onConfirm={() => {}}
|
|
157
|
+
/>
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Consent reached from the pane, and declined. The offer stays exactly where
|
|
163
|
+
* it was; declining costs nothing and is not asked about again.
|
|
164
|
+
*/
|
|
165
|
+
export const ConsentFlow: Story = {
|
|
166
|
+
render: () => {
|
|
167
|
+
const [open, setOpen] = useState(false);
|
|
168
|
+
const [confirmed, setConfirmed] = useState(false);
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<SelfUpdateSection
|
|
172
|
+
now={NOW}
|
|
173
|
+
state={{
|
|
174
|
+
status: "available",
|
|
175
|
+
version: CURRENT,
|
|
176
|
+
release: demoRelease,
|
|
177
|
+
}}
|
|
178
|
+
onCheck={() => {}}
|
|
179
|
+
onInstall={() => setOpen(true)}
|
|
180
|
+
onDismissResult={() => {}}
|
|
181
|
+
/>
|
|
182
|
+
{confirmed && (
|
|
183
|
+
<p className="mt-3 text-xs text-fg-subtle">
|
|
184
|
+
Consent given — the app hands over to the blocking restart screen
|
|
185
|
+
here.
|
|
186
|
+
</p>
|
|
187
|
+
)}
|
|
188
|
+
<SelfUpdateConfirmDialog
|
|
189
|
+
open={open}
|
|
190
|
+
currentVersion={CURRENT}
|
|
191
|
+
release={demoRelease}
|
|
192
|
+
onClose={() => setOpen(false)}
|
|
193
|
+
onConfirm={() => {
|
|
194
|
+
setOpen(false);
|
|
195
|
+
setConfirmed(true);
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
</>
|
|
199
|
+
);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pressing "Check again" while a check is already running. The control is
|
|
205
|
+
* never disabled — it no-ops and says why, per the UX tenets.
|
|
206
|
+
*/
|
|
207
|
+
export const PressingCheckWhileChecking: Story = {
|
|
208
|
+
args: withState({ status: "checking", version: CURRENT }),
|
|
209
|
+
play: async ({ canvasElement }) => {
|
|
210
|
+
canvasElement.querySelector<HTMLButtonElement>("button")?.click();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update state for a Remit you run yourself.
|
|
3
|
+
*
|
|
4
|
+
* The server is the thing being replaced, so the UI cannot assume it stays
|
|
5
|
+
* reachable across the operation. Every state here is one the client can be
|
|
6
|
+
* left holding on its own, and each names what the user can do next.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ReleaseInfo {
|
|
10
|
+
version: string;
|
|
11
|
+
releasedAt: number;
|
|
12
|
+
releaseNotesUrl: string;
|
|
13
|
+
/** One line of plain language, not a changelog dump. */
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Where the operation is between consent and a reachable server again. */
|
|
18
|
+
export type UpdatePhase = "preparing" | "restarting" | "reconnecting";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Identifies one update attempt. The client hands this back after the restart
|
|
22
|
+
* to ask what became of the run it started — its own memory of the operation
|
|
23
|
+
* does not survive the operation. Without it a reload mid-update cannot tell an
|
|
24
|
+
* update in flight from an ordinary server blip.
|
|
25
|
+
*/
|
|
26
|
+
export type UpdateRunId = string;
|
|
27
|
+
|
|
28
|
+
export type SelfUpdateState =
|
|
29
|
+
| { status: "upToDate"; version: string; checkedAt: number }
|
|
30
|
+
| { status: "checking"; version: string }
|
|
31
|
+
| {
|
|
32
|
+
status: "checkFailed";
|
|
33
|
+
version: string;
|
|
34
|
+
/** Plain-language cause, e.g. "no route to github.com". */
|
|
35
|
+
reason: string;
|
|
36
|
+
lastCheckedAt?: number;
|
|
37
|
+
}
|
|
38
|
+
| { status: "available"; version: string; release: ReleaseInfo }
|
|
39
|
+
| {
|
|
40
|
+
status: "applying";
|
|
41
|
+
runId: UpdateRunId;
|
|
42
|
+
version: string;
|
|
43
|
+
target: string;
|
|
44
|
+
phase: UpdatePhase;
|
|
45
|
+
/** Seconds since the user consented, for honest "still working" copy. */
|
|
46
|
+
elapsedSeconds: number;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
status: "succeeded";
|
|
50
|
+
runId: UpdateRunId;
|
|
51
|
+
version: string;
|
|
52
|
+
previousVersion: string;
|
|
53
|
+
releaseNotesUrl: string;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
status: "rolledBack";
|
|
57
|
+
runId: UpdateRunId;
|
|
58
|
+
/** The version running now — the old one, restored. */
|
|
59
|
+
version: string;
|
|
60
|
+
attemptedVersion: string;
|
|
61
|
+
/** The server's own account of the failure, shown verbatim. */
|
|
62
|
+
reason: string;
|
|
63
|
+
logsCommand: string;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
status: "unreachable";
|
|
67
|
+
runId: UpdateRunId;
|
|
68
|
+
previousVersion: string;
|
|
69
|
+
attemptedVersion: string;
|
|
70
|
+
elapsedSeconds: number;
|
|
71
|
+
logsCommand: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type SelfUpdateStatus = SelfUpdateState["status"];
|
|
75
|
+
|
|
76
|
+
const phaseLabels: Record<UpdatePhase, string> = {
|
|
77
|
+
preparing: "Getting the new version ready",
|
|
78
|
+
restarting: "Restarting Remit",
|
|
79
|
+
reconnecting: "Waiting for Remit to answer again",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function updatePhaseLabel(phase: UpdatePhase): string {
|
|
83
|
+
return phaseLabels[phase];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The restart takes the server away, so silence is expected for a while. Say so
|
|
88
|
+
* before the wait feels like a hang, and stop promising once it does. Nothing
|
|
89
|
+
* here may describe what the server is doing — from a lost connection the
|
|
90
|
+
* client only knows how long it has been quiet.
|
|
91
|
+
*/
|
|
92
|
+
export function updateWaitNote(elapsedSeconds: number): string {
|
|
93
|
+
if (elapsedSeconds < 90) return "This usually takes about a minute.";
|
|
94
|
+
if (elapsedSeconds < 240)
|
|
95
|
+
return "Longer than usual. Remit is still trying to reach the server.";
|
|
96
|
+
return "Still no answer. Remit keeps trying.";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatRelativeCheck(
|
|
100
|
+
checkedAt: number,
|
|
101
|
+
now: number = Date.now(),
|
|
102
|
+
): string {
|
|
103
|
+
const minutes = Math.floor((now - checkedAt) / 60_000);
|
|
104
|
+
if (minutes < 1) return "just now";
|
|
105
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
106
|
+
const hours = Math.floor(minutes / 60);
|
|
107
|
+
if (hours < 24) return `${hours}h ago`;
|
|
108
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function formatReleaseDate(epochMillis: number): string {
|
|
112
|
+
return new Date(epochMillis).toLocaleDateString(undefined, {
|
|
113
|
+
dateStyle: "medium",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const demoRelease: ReleaseInfo = {
|
|
118
|
+
version: "0.9.4",
|
|
119
|
+
releasedAt: Date.parse("2026-07-14T09:00:00.000Z"),
|
|
120
|
+
releaseNotesUrl: "https://github.com/remit-mail/reader/releases/tag/v0.9.4",
|
|
121
|
+
summary:
|
|
122
|
+
"Faster first sync on large mailboxes, and search no longer misses mail moved between folders.",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const demoLogsCommand = "remit logs --since 10m";
|
|
126
|
+
|
|
127
|
+
export const demoRunId = "upd_8f3c21a7";
|
|
@@ -20,6 +20,12 @@ const plural = (n: number): string => (n === 1 ? "result" : "results");
|
|
|
20
20
|
* the whole point of the folder is that its contents are unwanted until asked
|
|
21
21
|
* for. Taking the offer scopes the search to Spam.
|
|
22
22
|
*
|
|
23
|
+
* The count is every match held out; the action opens Spam. It deliberately
|
|
24
|
+
* does not read "view them", because scope is a single mailbox and a user with
|
|
25
|
+
* several accounts has several Spam folders — the count would then name more
|
|
26
|
+
* mail than the click delivers. Saying where the button goes is true whatever
|
|
27
|
+
* the account setup.
|
|
28
|
+
*
|
|
23
29
|
* Quiet on purpose: this is an offer, not a warning. Presentational — the
|
|
24
30
|
* caller owns what "scope to spam" does.
|
|
25
31
|
*/
|
|
@@ -44,7 +50,7 @@ export function SpamResultsOffer({
|
|
|
44
50
|
onClick={onScopeToSpam}
|
|
45
51
|
className="shrink-0 text-accent"
|
|
46
52
|
>
|
|
47
|
-
|
|
53
|
+
Go to Spam
|
|
48
54
|
</Button>
|
|
49
55
|
</div>
|
|
50
56
|
</Banner>
|
package/src/index.ts
CHANGED
|
@@ -379,6 +379,38 @@ export {
|
|
|
379
379
|
type SelectionTopBarNoticeAction,
|
|
380
380
|
type SelectionTopBarProps,
|
|
381
381
|
} from "./components/selection-top-bar.js";
|
|
382
|
+
export {
|
|
383
|
+
demoLogsCommand,
|
|
384
|
+
demoRelease,
|
|
385
|
+
demoRunId,
|
|
386
|
+
formatRelativeCheck,
|
|
387
|
+
formatReleaseDate,
|
|
388
|
+
type ReleaseInfo,
|
|
389
|
+
type SelfUpdateState,
|
|
390
|
+
type SelfUpdateStatus,
|
|
391
|
+
type UpdatePhase,
|
|
392
|
+
type UpdateRunId,
|
|
393
|
+
updatePhaseLabel,
|
|
394
|
+
updateWaitNote,
|
|
395
|
+
} from "./components/self-update.js";
|
|
396
|
+
export {
|
|
397
|
+
SelfUpdateConfirmDialog,
|
|
398
|
+
type SelfUpdateConfirmDialogProps,
|
|
399
|
+
} from "./components/self-update-confirm-dialog.js";
|
|
400
|
+
export {
|
|
401
|
+
UpdateAvailableDot,
|
|
402
|
+
type UpdateAvailableDotProps,
|
|
403
|
+
} from "./components/self-update-dot.js";
|
|
404
|
+
export {
|
|
405
|
+
SelfUpdateProgressOverlay,
|
|
406
|
+
type SelfUpdateProgressOverlayProps,
|
|
407
|
+
SelfUpdateUnreachableScreen,
|
|
408
|
+
type SelfUpdateUnreachableScreenProps,
|
|
409
|
+
} from "./components/self-update-progress-overlay.js";
|
|
410
|
+
export {
|
|
411
|
+
SelfUpdateSection,
|
|
412
|
+
type SelfUpdateSectionProps,
|
|
413
|
+
} from "./components/self-update-section.js";
|
|
382
414
|
export {
|
|
383
415
|
type SenderGroupOption,
|
|
384
416
|
SenderGroupSwitch,
|