@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.23",
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
- moveDisabledHint={moveDisabledHint}
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
+ });
@@ -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 — use “Copy full details” for the full stacktrace)";
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
- function buildIssueBody(ctx: BugReportContext, stack?: string): string {
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
- const resolvedStack = stack ?? ctx.stack;
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, stack?: string): string {
158
+ function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
122
159
  const params = new URLSearchParams({
123
160
  title: buildIssueTitle(ctx),
124
- body: buildIssueBody(ctx, stack),
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 stacktrace is truncated (binary-searched to the longest
141
- * prefix that fits) and marked; everything else is preserved. The component
142
- * stack is dropped from the URL when truncation kicks in — it survives in the
143
- * "Copy full details" report.
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
- let lo = 0;
156
- let hi = stack.length;
157
- let best = 0;
158
- while (lo <= hi) {
159
- const mid = (lo + hi) >> 1;
160
- const candidate = issueUrl(
161
- withoutComponentStack,
162
- stack.slice(0, mid) + TRUNCATION_MARKER,
163
- );
164
- if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
165
- best = mid;
166
- lo = mid + 1;
167
- } else {
168
- hi = mid - 1;
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
  }