@remit/web-client 0.0.23 → 0.0.25
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"@remit/drizzle-service": "*",
|
|
104
104
|
"@remit/electrodb-entities": "*",
|
|
105
105
|
"@remit/secrets-service": "*",
|
|
106
|
+
"@storybook/react-vite": "^9",
|
|
106
107
|
"@tailwindcss/vite": "^4",
|
|
107
108
|
"@tanstack/router-generator": "^1",
|
|
108
109
|
"@tanstack/router-plugin": "^1",
|
|
@@ -802,7 +802,11 @@ export const MessageList = ({
|
|
|
802
802
|
onDelete={handleDelete}
|
|
803
803
|
onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
804
804
|
isBusy={isDeleting || isMoving}
|
|
805
|
-
|
|
805
|
+
notice={
|
|
806
|
+
moveDisabledHint
|
|
807
|
+
? { tone: "warning", text: moveDisabledHint }
|
|
808
|
+
: undefined
|
|
809
|
+
}
|
|
806
810
|
moveSlot={
|
|
807
811
|
onMoveMessages && accountId && mailboxId ? (
|
|
808
812
|
<>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { ConfirmDialog } from "./ConfirmDialog";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConfirmDialog> = {
|
|
5
|
+
title: "Screens/WebClient/ConfirmDialog",
|
|
6
|
+
component: ConfirmDialog,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
args: {
|
|
9
|
+
isOpen: true,
|
|
10
|
+
title: "Move 3,412 messages to Trash?",
|
|
11
|
+
description: "You can restore them from Trash later.",
|
|
12
|
+
confirmLabel: "Move to Trash",
|
|
13
|
+
destructive: true,
|
|
14
|
+
onConfirm: () => undefined,
|
|
15
|
+
onCancel: () => undefined,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof ConfirmDialog>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single corner tap on the bar's delete icon used to fall straight through
|
|
24
|
+
* to a delete with nothing in between — this is what now sits in the way.
|
|
25
|
+
* Wording says "Move … to Trash", not "Delete": the operation is reversible
|
|
26
|
+
* (IMAP delete moves to Trash), and the confirmation copy says so rather than
|
|
27
|
+
* reading as final.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {};
|
|
30
|
+
|
|
31
|
+
export const OneMessage: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
title: "Move 1 message to Trash?",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The mutation is in flight: the confirm button disables rather than
|
|
38
|
+
* allowing a second concurrent delete request. */
|
|
39
|
+
export const Busy: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
isBusy: true,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A non-destructive confirmation (no `destructive`) uses the accent
|
|
46
|
+
* affirmative styling instead of danger. */
|
|
47
|
+
export const NonDestructive: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
title: "Archive 12 messages?",
|
|
50
|
+
description: undefined,
|
|
51
|
+
confirmLabel: "Archive",
|
|
52
|
+
destructive: false,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
QUARANTINE_REPORT_DISCLAIMER,
|
|
5
|
+
type QuarantineEntry,
|
|
6
|
+
quarantineDemoEntries,
|
|
7
|
+
quarantineIssueTitle,
|
|
8
|
+
quarantineReportSections,
|
|
9
|
+
} from "@remit/ui";
|
|
3
10
|
import type { BugReportContext } from "./bug-report";
|
|
4
11
|
import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
|
|
5
12
|
|
|
@@ -211,3 +218,88 @@ describe("buildGitHubIssueUrl — truncation to fit the URL budget", () => {
|
|
|
211
218
|
);
|
|
212
219
|
});
|
|
213
220
|
});
|
|
221
|
+
|
|
222
|
+
describe("quarantine reports", () => {
|
|
223
|
+
// A flat multipart/mixed of 200 parts — the shape that first broke the
|
|
224
|
+
// fence. A message with a pathological part count is precisely the message
|
|
225
|
+
// that fails to parse, so a long report is the common case, not the tail.
|
|
226
|
+
const entry: QuarantineEntry = {
|
|
227
|
+
...quarantineDemoEntries[0],
|
|
228
|
+
structure: {
|
|
229
|
+
contentType: "multipart/mixed",
|
|
230
|
+
parts: Array.from({ length: 200 }, () => ({
|
|
231
|
+
contentType: "application/octet-stream",
|
|
232
|
+
})),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const ctx = (over?: Partial<BugReportContext>): BugReportContext => ({
|
|
237
|
+
...baseCtx,
|
|
238
|
+
title: quarantineIssueTitle(entry),
|
|
239
|
+
quarantine: quarantineReportSections(entry),
|
|
240
|
+
...over,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const fenceCount = (body: string): number =>
|
|
244
|
+
(body.match(/^```/gm) ?? []).length;
|
|
245
|
+
|
|
246
|
+
it("carries the diagnostics section and the supplied title", () => {
|
|
247
|
+
const decoded = decode(
|
|
248
|
+
buildGitHubIssueUrl(
|
|
249
|
+
ctx({ quarantine: quarantineReportSections(quarantineDemoEntries[0]) }),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
assert.ok(decoded.includes("UnterminatedMultipartBoundary"));
|
|
253
|
+
assert.ok(decoded.includes("Message quarantined:"));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("truncates a long report without cutting inside the code fence", () => {
|
|
257
|
+
const url = buildGitHubIssueUrl(ctx());
|
|
258
|
+
const body = decode(url);
|
|
259
|
+
assert.ok(
|
|
260
|
+
url.length <= 8000,
|
|
261
|
+
`Expected a URL under 8000 chars, got ${url.length}`,
|
|
262
|
+
);
|
|
263
|
+
assert.ok(body.includes("truncated"), "Expected a truncation marker");
|
|
264
|
+
assert.equal(
|
|
265
|
+
fenceCount(body) % 2,
|
|
266
|
+
0,
|
|
267
|
+
`Unbalanced code fence in truncated body:\n${body.slice(-400)}`,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps the redaction disclaimer on a truncated report", () => {
|
|
272
|
+
const body = decode(buildGitHubIssueUrl(ctx()));
|
|
273
|
+
assert.ok(
|
|
274
|
+
body.includes(QUARANTINE_REPORT_DISCLAIMER),
|
|
275
|
+
"A truncated report must still state what was withheld",
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps the reproduction template outside the code block", () => {
|
|
280
|
+
const body = decode(buildGitHubIssueUrl(ctx()));
|
|
281
|
+
const afterLastFence = body.slice(body.lastIndexOf("```") + 3);
|
|
282
|
+
assert.ok(afterLastFence.includes("## Steps to reproduce"));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("truncates the longer section when a stack is present too", () => {
|
|
286
|
+
const url = buildGitHubIssueUrl(
|
|
287
|
+
ctx({ stack: "at foo\n".repeat(2000), errorMessage: "boom" }),
|
|
288
|
+
);
|
|
289
|
+
assert.ok(
|
|
290
|
+
url.length <= 8000,
|
|
291
|
+
`Expected a URL under 8000 chars, got ${url.length}`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("Copy full details keeps every part the URL had to drop", () => {
|
|
296
|
+
const parts = (text: string): number =>
|
|
297
|
+
(text.match(/- application\/octet-stream/g) ?? []).length;
|
|
298
|
+
const details = buildBugReportDetails(ctx());
|
|
299
|
+
assert.equal(parts(details), 200);
|
|
300
|
+
assert.ok(
|
|
301
|
+
parts(decode(buildGitHubIssueUrl(ctx()))) < 200,
|
|
302
|
+
"Expected the URL body to have dropped parts",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|
package/src/lib/bug-report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { QuarantineReportSections } from "@remit/ui";
|
|
1
2
|
import {
|
|
2
3
|
APP_BUILD_TIME,
|
|
3
4
|
APP_SHA,
|
|
@@ -15,7 +16,7 @@ import { getRecentErrors } from "./console-errors";
|
|
|
15
16
|
const MAX_ISSUE_URL_LENGTH = 7500;
|
|
16
17
|
|
|
17
18
|
const TRUNCATION_MARKER =
|
|
18
|
-
"\n… (truncated —
|
|
19
|
+
"\n… (truncated — the copy action on this screen has the full report)";
|
|
19
20
|
|
|
20
21
|
export interface BugReportContext {
|
|
21
22
|
appSha: string;
|
|
@@ -33,6 +34,14 @@ export interface BugReportContext {
|
|
|
33
34
|
stack?: string;
|
|
34
35
|
/** React component stack from the error boundary, when available. */
|
|
35
36
|
componentStack?: string;
|
|
37
|
+
/**
|
|
38
|
+
* A quarantined message's diagnostics, when the report is filed from the
|
|
39
|
+
* quarantine surface. Split into sections by `quarantineReportSections` in
|
|
40
|
+
* @remit/ui so the MIME tree is fenced after truncation, never before.
|
|
41
|
+
*/
|
|
42
|
+
quarantine?: QuarantineReportSections;
|
|
43
|
+
/** Overrides the derived title, e.g. for a quarantine report. */
|
|
44
|
+
title?: string;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/** Fields callers can seed from a caught error. */
|
|
@@ -40,6 +49,9 @@ export interface BugReportSeed {
|
|
|
40
49
|
errorMessage?: string;
|
|
41
50
|
stack?: string;
|
|
42
51
|
componentStack?: string;
|
|
52
|
+
quarantine?: QuarantineReportSections;
|
|
53
|
+
/** Overrides the derived title, e.g. for a quarantine report. */
|
|
54
|
+
title?: string;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
@@ -56,6 +68,8 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
|
56
68
|
errorMessage: seed?.errorMessage,
|
|
57
69
|
stack: seed?.stack,
|
|
58
70
|
componentStack: seed?.componentStack,
|
|
71
|
+
quarantine: seed?.quarantine,
|
|
72
|
+
title: seed?.title,
|
|
59
73
|
};
|
|
60
74
|
}
|
|
61
75
|
|
|
@@ -63,7 +77,16 @@ function fencedBlock(content: string): string {
|
|
|
63
77
|
return ["```", content, "```"].join("\n");
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
|
|
80
|
+
interface BodyOverrides {
|
|
81
|
+
stack?: string;
|
|
82
|
+
/** Replaces the MIME-tree section only; it is fenced after this is applied. */
|
|
83
|
+
quarantineStructure?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildIssueBody(
|
|
87
|
+
ctx: BugReportContext,
|
|
88
|
+
overrides?: BodyOverrides,
|
|
89
|
+
): string {
|
|
67
90
|
const errorSection =
|
|
68
91
|
ctx.recentErrors.length > 0
|
|
69
92
|
? ctx.recentErrors.map((e) => ` - ${e}`).join("\n")
|
|
@@ -85,7 +108,20 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
|
|
|
85
108
|
lines.push("## Error", ctx.errorMessage, "");
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
|
|
111
|
+
if (ctx.quarantine) {
|
|
112
|
+
const structure =
|
|
113
|
+
overrides?.quarantineStructure ?? ctx.quarantine.structure;
|
|
114
|
+
lines.push(
|
|
115
|
+
ctx.quarantine.head,
|
|
116
|
+
"",
|
|
117
|
+
fencedBlock(structure),
|
|
118
|
+
"",
|
|
119
|
+
ctx.quarantine.disclaimer,
|
|
120
|
+
"",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedStack = overrides?.stack ?? ctx.stack;
|
|
89
125
|
if (resolvedStack) {
|
|
90
126
|
lines.push("## Stacktrace", fencedBlock(resolvedStack), "");
|
|
91
127
|
}
|
|
@@ -111,6 +147,7 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
|
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
function buildIssueTitle(ctx: BugReportContext): string {
|
|
150
|
+
if (ctx.title) return ctx.title;
|
|
114
151
|
if (!ctx.errorMessage) return "Bug: ";
|
|
115
152
|
const firstLine = ctx.errorMessage.split("\n")[0].trim();
|
|
116
153
|
const clipped =
|
|
@@ -118,10 +155,10 @@ function buildIssueTitle(ctx: BugReportContext): string {
|
|
|
118
155
|
return `Bug: ${clipped}`;
|
|
119
156
|
}
|
|
120
157
|
|
|
121
|
-
function issueUrl(ctx: BugReportContext,
|
|
158
|
+
function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
|
|
122
159
|
const params = new URLSearchParams({
|
|
123
160
|
title: buildIssueTitle(ctx),
|
|
124
|
-
body: buildIssueBody(ctx,
|
|
161
|
+
body: buildIssueBody(ctx, overrides),
|
|
125
162
|
});
|
|
126
163
|
return `${GITHUB_NEW_ISSUE_URL}?${params.toString()}`;
|
|
127
164
|
}
|
|
@@ -137,40 +174,63 @@ export function buildBugReportDetails(ctx: BugReportContext): string {
|
|
|
137
174
|
|
|
138
175
|
/**
|
|
139
176
|
* Build a prefilled GitHub new-issue URL. When the full body would exceed the
|
|
140
|
-
* URL budget, the
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
177
|
+
* URL budget, the longest truncatable section — a stacktrace, or a quarantine
|
|
178
|
+
* report's MIME tree — is binary-searched to the longest prefix that fits and
|
|
179
|
+
* marked; everything else is preserved. The component stack is dropped from
|
|
180
|
+
* the URL when truncation kicks in — it survives in the copy-details report.
|
|
181
|
+
*
|
|
182
|
+
* The quarantine MIME tree is handed over unfenced and fenced by
|
|
183
|
+
* `buildIssueBody` afterwards, so a cut can never land inside a code fence.
|
|
184
|
+
* The disclaimer is appended by the builder rather than carried in the
|
|
185
|
+
* truncated text, so a truncated report still states what was withheld.
|
|
144
186
|
*/
|
|
145
187
|
export function buildGitHubIssueUrl(ctx: BugReportContext): string {
|
|
146
188
|
const full = issueUrl(ctx);
|
|
147
189
|
if (full.length <= MAX_ISSUE_URL_LENGTH) return full;
|
|
148
190
|
|
|
149
|
-
const stack = ctx.stack ?? "";
|
|
150
191
|
const withoutComponentStack: BugReportContext = {
|
|
151
192
|
...ctx,
|
|
152
193
|
componentStack: undefined,
|
|
153
194
|
};
|
|
154
195
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
// Both sections can be present — a seed is not discriminated — and cutting
|
|
197
|
+
// one to nothing does not necessarily fit the other. Truncate the longest
|
|
198
|
+
// first and keep going while the budget is still exceeded.
|
|
199
|
+
const sections: Array<[keyof BodyOverrides, string]> = [];
|
|
200
|
+
if (ctx.stack) sections.push(["stack", ctx.stack]);
|
|
201
|
+
if (ctx.quarantine?.structure) {
|
|
202
|
+
sections.push(["quarantineStructure", ctx.quarantine.structure]);
|
|
203
|
+
}
|
|
204
|
+
sections.sort((a, b) => b[1].length - a[1].length);
|
|
205
|
+
|
|
206
|
+
let overrides: BodyOverrides = {};
|
|
207
|
+
for (const [field, text] of sections) {
|
|
208
|
+
if (
|
|
209
|
+
issueUrl(withoutComponentStack, overrides).length <= MAX_ISSUE_URL_LENGTH
|
|
210
|
+
) {
|
|
211
|
+
break;
|
|
169
212
|
}
|
|
213
|
+
let lo = 0;
|
|
214
|
+
let hi = text.length;
|
|
215
|
+
let best = 0;
|
|
216
|
+
while (lo <= hi) {
|
|
217
|
+
const mid = (lo + hi) >> 1;
|
|
218
|
+
const candidate = issueUrl(withoutComponentStack, {
|
|
219
|
+
...overrides,
|
|
220
|
+
[field]: text.slice(0, mid) + TRUNCATION_MARKER,
|
|
221
|
+
});
|
|
222
|
+
if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
|
|
223
|
+
best = mid;
|
|
224
|
+
lo = mid + 1;
|
|
225
|
+
} else {
|
|
226
|
+
hi = mid - 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
overrides = {
|
|
230
|
+
...overrides,
|
|
231
|
+
[field]: text.slice(0, best) + TRUNCATION_MARKER,
|
|
232
|
+
};
|
|
170
233
|
}
|
|
171
234
|
|
|
172
|
-
return issueUrl(
|
|
173
|
-
withoutComponentStack,
|
|
174
|
-
stack.slice(0, best) + TRUNCATION_MARKER,
|
|
175
|
-
);
|
|
235
|
+
return issueUrl(withoutComponentStack, overrides);
|
|
176
236
|
}
|