@remit/web-client 0.0.138 → 0.0.140
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 +2 -2
- package/src/components/compose/AddressField.tsx +20 -188
- package/src/components/compose/ComposeForm.tsx +51 -87
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +10 -35
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/mail/DailyBrief.tsx +91 -31
- package/src/components/mail/MailListHeader.tsx +5 -1
- package/src/components/mail/MessageList.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.tsx +6 -2
- package/src/components/settings/label-delete-confirm.stories.tsx +1 -1
- package/src/lib/brief-chip-query.test.ts +85 -0
- package/src/lib/organize/search-to-rule.test.ts +15 -1
- package/src/lib/search-tokens.ts +20 -86
- package/src/routes/settings/labels.tsx +1 -1
- package/src/components/compose/ComposeBody.stories.tsx +0 -463
- package/src/components/compose/ComposeBody.tsx +0 -210
- package/src/components/compose/SubjectField.tsx +0 -19
- package/src/components/compose/compose-mode.test.ts +0 -76
- package/src/components/compose/compose-mode.ts +0 -50
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -54
- package/src/components/ui/ConfirmDialog.tsx +0 -140
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which surface a draft reopens in, and when a mode switch is refused.
|
|
3
|
-
*
|
|
4
|
-
* The mode is derived from `htmlBody`, with no field of its own. It has to be
|
|
5
|
-
* "a non-empty string" and not "truthy": the rich editor serializes an empty
|
|
6
|
-
* document to `<p><br></p>` and a plain draft clears the column to `""`, so a
|
|
7
|
-
* falsy check opens a plain draft correctly by accident and an absent column
|
|
8
|
-
* — an old draft, a partial write — the wrong way round.
|
|
9
|
-
*/
|
|
10
|
-
import assert from "node:assert/strict";
|
|
11
|
-
import { describe, it } from "node:test";
|
|
12
|
-
import {
|
|
13
|
-
conversionOutcome,
|
|
14
|
-
modeOfDraft,
|
|
15
|
-
switchNeedsWarning,
|
|
16
|
-
} from "./compose-mode.js";
|
|
17
|
-
|
|
18
|
-
describe("the mode a draft reopens in", () => {
|
|
19
|
-
it("opens a draft with HTML as rich", () => {
|
|
20
|
-
assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("opens an empty rich document as rich", () => {
|
|
24
|
-
assert.equal(modeOfDraft("<p><br></p>"), "rich");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("opens a draft whose HTML was cleared as plain", () => {
|
|
28
|
-
assert.equal(modeOfDraft(""), "plain");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("opens a draft that never had HTML as plain", () => {
|
|
32
|
-
assert.equal(modeOfDraft(undefined), "plain");
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("whether the switch warns first", () => {
|
|
37
|
-
it("warns when the document holds formatting", () => {
|
|
38
|
-
assert.equal(switchNeedsWarning("plain", ["table"]), true);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("says nothing over plain paragraphs", () => {
|
|
42
|
-
assert.equal(switchNeedsWarning("plain", []), false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("never warns on the way back to rich", () => {
|
|
46
|
-
assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("a conversion that would empty a written message", () => {
|
|
51
|
-
it("goes ahead when the conversion carried the message across", () => {
|
|
52
|
-
assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
|
|
53
|
-
outcome: "switch",
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("goes ahead when there was nothing to carry", () => {
|
|
58
|
-
assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("refuses, naming the direction, when plain text came back empty", () => {
|
|
62
|
-
assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
|
|
63
|
-
outcome: "blocked",
|
|
64
|
-
title: "Couldn't switch to plain text",
|
|
65
|
-
detail: "The conversion came back empty, so your message is unchanged.",
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("refuses, naming the direction, when rich text came back empty", () => {
|
|
70
|
-
assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
|
|
71
|
-
outcome: "blocked",
|
|
72
|
-
title: "Couldn't switch to rich text",
|
|
73
|
-
detail: "The conversion came back empty, so your message is unchanged.",
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Which surface a draft reopens in, with no field of its own. `htmlBody` a
|
|
5
|
-
* non-empty string is a rich draft; anything else is plain.
|
|
6
|
-
*
|
|
7
|
-
* Not "falsy": the rich editor serializes an empty document to `<p><br></p>`,
|
|
8
|
-
* so a rich draft's `htmlBody` is never absent, and a plain draft clears the
|
|
9
|
-
* column to the empty string rather than omitting it — absent means "leave
|
|
10
|
-
* alone" at every layer below this one.
|
|
11
|
-
*/
|
|
12
|
-
export const modeOfDraft = (htmlBody: string | undefined): ComposeBodyMode =>
|
|
13
|
-
typeof htmlBody === "string" && htmlBody.length > 0 ? "rich" : "plain";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Whether switching to plain text destroys something. True for any node type or
|
|
17
|
-
* text format the document holds that plain text cannot carry.
|
|
18
|
-
*/
|
|
19
|
-
export const switchNeedsWarning = (
|
|
20
|
-
target: ComposeBodyMode,
|
|
21
|
-
formatting: readonly string[],
|
|
22
|
-
): boolean => target === "plain" && formatting.length > 0;
|
|
23
|
-
|
|
24
|
-
export type ConversionOutcome =
|
|
25
|
-
| { outcome: "switch" }
|
|
26
|
-
| { outcome: "blocked"; title: string; detail: string };
|
|
27
|
-
|
|
28
|
-
const BLOCKED_TITLES: Record<ComposeBodyMode, string> = {
|
|
29
|
-
plain: "Couldn't switch to plain text",
|
|
30
|
-
rich: "Couldn't switch to rich text",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A conversion that empties a written message does not happen. Autosave would
|
|
35
|
-
* persist the blank body a moment later, so the draft would be gone with
|
|
36
|
-
* nothing said. An empty body converting to an empty body is not this case.
|
|
37
|
-
*/
|
|
38
|
-
export const conversionOutcome = (
|
|
39
|
-
target: ComposeBodyMode,
|
|
40
|
-
source: string,
|
|
41
|
-
converted: string,
|
|
42
|
-
): ConversionOutcome => {
|
|
43
|
-
if (source.trim() === "" || converted.trim() !== "")
|
|
44
|
-
return { outcome: "switch" };
|
|
45
|
-
return {
|
|
46
|
-
outcome: "blocked",
|
|
47
|
-
title: BLOCKED_TITLES[target],
|
|
48
|
-
detail: "The conversion came back empty, so your message is unchanged.",
|
|
49
|
-
};
|
|
50
|
-
};
|
|
@@ -1,54 +0,0 @@
|
|
|
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,140 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { cn } from "@/lib/utils";
|
|
3
|
-
|
|
4
|
-
interface ConfirmDialogProps {
|
|
5
|
-
isOpen: boolean;
|
|
6
|
-
title: string;
|
|
7
|
-
/** Optional supporting line under the title. */
|
|
8
|
-
description?: string;
|
|
9
|
-
confirmLabel: string;
|
|
10
|
-
cancelLabel?: string;
|
|
11
|
-
/** Style the confirm button as a destructive action. */
|
|
12
|
-
destructive?: boolean;
|
|
13
|
-
/** Disable the confirm button (e.g. while a mutation is in flight). */
|
|
14
|
-
isBusy?: boolean;
|
|
15
|
-
onConfirm: () => void;
|
|
16
|
-
onCancel: () => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
|
|
21
|
-
* primitive ships in the web client (only the bespoke KeyboardShortcutsModal
|
|
22
|
-
* and SlidePanel), so this is a small reusable one matching their Tailwind +
|
|
23
|
-
* overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
|
|
24
|
-
* open, and the confirm/cancel pair is the only focusable content so the focus
|
|
25
|
-
* stays within the dialog.
|
|
26
|
-
*/
|
|
27
|
-
export const ConfirmDialog = ({
|
|
28
|
-
isOpen,
|
|
29
|
-
title,
|
|
30
|
-
description,
|
|
31
|
-
confirmLabel,
|
|
32
|
-
cancelLabel = "Cancel",
|
|
33
|
-
destructive = false,
|
|
34
|
-
isBusy = false,
|
|
35
|
-
onConfirm,
|
|
36
|
-
onCancel,
|
|
37
|
-
}: ConfirmDialogProps) => {
|
|
38
|
-
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
39
|
-
|
|
40
|
-
const handleKeyDown = useCallback(
|
|
41
|
-
(event: KeyboardEvent) => {
|
|
42
|
-
if (event.key === "Escape") {
|
|
43
|
-
event.preventDefault();
|
|
44
|
-
event.stopPropagation();
|
|
45
|
-
event.stopImmediatePropagation();
|
|
46
|
-
onCancel();
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
[onCancel],
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (!isOpen) return;
|
|
54
|
-
// Capture phase so Esc closes the dialog before any list-level Esc
|
|
55
|
-
// handler (e.g. clearSelection) also fires on the same keystroke.
|
|
56
|
-
window.addEventListener("keydown", handleKeyDown, true);
|
|
57
|
-
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
58
|
-
}, [isOpen, handleKeyDown]);
|
|
59
|
-
|
|
60
|
-
// Whoever opened the dialog gets the focus back when it closes. Without this
|
|
61
|
-
// a cancelled confirmation drops focus to the body, and the control the user
|
|
62
|
-
// was on — the compose mode toggle, a row's delete button — is gone from
|
|
63
|
-
// under the keyboard.
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (!isOpen) return;
|
|
66
|
-
const opener =
|
|
67
|
-
document.activeElement instanceof HTMLElement
|
|
68
|
-
? document.activeElement
|
|
69
|
-
: null;
|
|
70
|
-
cancelRef.current?.focus();
|
|
71
|
-
return () => {
|
|
72
|
-
if (opener?.isConnected) opener.focus();
|
|
73
|
-
};
|
|
74
|
-
}, [isOpen]);
|
|
75
|
-
|
|
76
|
-
if (!isOpen) return null;
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
// Above every other overlay, the mobile compose sheet included: a
|
|
80
|
-
// confirmation is the decision blocking whatever is under it, and a drawer
|
|
81
|
-
// portalled to the body at the same level would cover it.
|
|
82
|
-
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
83
|
-
{/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
|
|
84
|
-
dialog itself must stay in the accessibility tree, and an
|
|
85
|
-
aria-hidden ancestor would take it out. */}
|
|
86
|
-
<div
|
|
87
|
-
className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
|
|
88
|
-
aria-hidden="true"
|
|
89
|
-
onClick={onCancel}
|
|
90
|
-
/>
|
|
91
|
-
|
|
92
|
-
{/* Dialog */}
|
|
93
|
-
<div
|
|
94
|
-
role="dialog"
|
|
95
|
-
aria-modal="true"
|
|
96
|
-
aria-label={title}
|
|
97
|
-
className={cn(
|
|
98
|
-
"relative z-10 w-full max-w-sm",
|
|
99
|
-
"bg-surface border border-line rounded-sm shadow-lg",
|
|
100
|
-
"p-6",
|
|
101
|
-
)}
|
|
102
|
-
onClick={(e) => e.stopPropagation()}
|
|
103
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
104
|
-
>
|
|
105
|
-
<h2 className="text-lg font-semibold">{title}</h2>
|
|
106
|
-
{description && (
|
|
107
|
-
<p className="mt-2 text-sm text-fg-muted">{description}</p>
|
|
108
|
-
)}
|
|
109
|
-
|
|
110
|
-
<div className="mt-6 flex items-center justify-end gap-2">
|
|
111
|
-
<button
|
|
112
|
-
ref={cancelRef}
|
|
113
|
-
type="button"
|
|
114
|
-
onClick={onCancel}
|
|
115
|
-
className={cn(
|
|
116
|
-
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
117
|
-
"border border-line hover:bg-surface-raised",
|
|
118
|
-
)}
|
|
119
|
-
>
|
|
120
|
-
{cancelLabel}
|
|
121
|
-
</button>
|
|
122
|
-
<button
|
|
123
|
-
type="button"
|
|
124
|
-
onClick={onConfirm}
|
|
125
|
-
disabled={isBusy}
|
|
126
|
-
className={cn(
|
|
127
|
-
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
128
|
-
destructive
|
|
129
|
-
? "bg-danger text-canvas hover:bg-danger/90"
|
|
130
|
-
: "bg-accent text-accent-fg hover:bg-accent-hover",
|
|
131
|
-
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
132
|
-
)}
|
|
133
|
-
>
|
|
134
|
-
{confirmLabel}
|
|
135
|
-
</button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
};
|