@remit/web-client 0.0.88 → 0.0.90
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/hooks/useKeyboardNavigation.ts +0 -73
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/lib/delete-folder.ts +0 -9
- package/src/lib/format.ts +0 -41
- package/src/lib/plate-serializer.ts +0 -112
- package/src/types/index.ts +0 -6
- package/src/components/mail/MessageDetail.tsx +0 -154
- package/src/lib/search-schemas.ts +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.90",
|
|
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": {
|
|
@@ -117,76 +117,3 @@ export const useKeyboardNavigation = ({
|
|
|
117
117
|
return () => window.removeEventListener("keydown", handleKeyDown, capture);
|
|
118
118
|
}, [enabled, handleKeyDown, capture]);
|
|
119
119
|
};
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Simple hook for list navigation with j/k keys.
|
|
123
|
-
*/
|
|
124
|
-
export const useListNavigation = <T extends { id: string }>({
|
|
125
|
-
items,
|
|
126
|
-
selectedId,
|
|
127
|
-
onSelect,
|
|
128
|
-
enabled = true,
|
|
129
|
-
}: {
|
|
130
|
-
items: T[];
|
|
131
|
-
selectedId: string | undefined;
|
|
132
|
-
onSelect: (id: string) => void;
|
|
133
|
-
enabled?: boolean;
|
|
134
|
-
}) => {
|
|
135
|
-
const currentIndex = selectedId
|
|
136
|
-
? items.findIndex((item) => item.id === selectedId)
|
|
137
|
-
: -1;
|
|
138
|
-
|
|
139
|
-
const selectNext = useCallback(() => {
|
|
140
|
-
if (items.length === 0) return;
|
|
141
|
-
const nextIndex =
|
|
142
|
-
currentIndex < items.length - 1 ? currentIndex + 1 : currentIndex;
|
|
143
|
-
if (nextIndex >= 0 && nextIndex < items.length) {
|
|
144
|
-
onSelect(items[nextIndex].id);
|
|
145
|
-
}
|
|
146
|
-
}, [items, currentIndex, onSelect]);
|
|
147
|
-
|
|
148
|
-
const selectPrevious = useCallback(() => {
|
|
149
|
-
if (items.length === 0) return;
|
|
150
|
-
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
|
151
|
-
if (prevIndex >= 0 && prevIndex < items.length) {
|
|
152
|
-
onSelect(items[prevIndex].id);
|
|
153
|
-
}
|
|
154
|
-
}, [items, currentIndex, onSelect]);
|
|
155
|
-
|
|
156
|
-
const selectFirst = useCallback(() => {
|
|
157
|
-
if (items.length > 0) {
|
|
158
|
-
onSelect(items[0].id);
|
|
159
|
-
}
|
|
160
|
-
}, [items, onSelect]);
|
|
161
|
-
|
|
162
|
-
const selectLast = useCallback(() => {
|
|
163
|
-
if (items.length > 0) {
|
|
164
|
-
onSelect(items[items.length - 1].id);
|
|
165
|
-
}
|
|
166
|
-
}, [items, onSelect]);
|
|
167
|
-
|
|
168
|
-
useKeyboardNavigation({
|
|
169
|
-
enabled,
|
|
170
|
-
bindings: [
|
|
171
|
-
{ key: "j", handler: selectNext, preventDefault: true },
|
|
172
|
-
{ key: "ArrowDown", handler: selectNext, preventDefault: true },
|
|
173
|
-
{ key: "k", handler: selectPrevious, preventDefault: true },
|
|
174
|
-
{ key: "ArrowUp", handler: selectPrevious, preventDefault: true },
|
|
175
|
-
{ key: "g", handler: selectFirst, preventDefault: true },
|
|
176
|
-
{
|
|
177
|
-
key: "G",
|
|
178
|
-
handler: selectLast,
|
|
179
|
-
noModifiers: false,
|
|
180
|
-
preventDefault: true,
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
currentIndex,
|
|
187
|
-
selectNext,
|
|
188
|
-
selectPrevious,
|
|
189
|
-
selectFirst,
|
|
190
|
-
selectLast,
|
|
191
|
-
};
|
|
192
|
-
};
|
|
Binary file
|
package/src/lib/delete-folder.ts
CHANGED
|
@@ -134,15 +134,6 @@ export function moveProgressLabel(progress: MoveProgress): string {
|
|
|
134
134
|
return `Moved ${progress.moved} of ${progress.total}`;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export type DeleteStage =
|
|
138
|
-
| "confirm-empty"
|
|
139
|
-
| "choose-fate"
|
|
140
|
-
| "confirm-delete-all"
|
|
141
|
-
| "pick-destination"
|
|
142
|
-
| "moving"
|
|
143
|
-
| "deleting"
|
|
144
|
-
| "error";
|
|
145
|
-
|
|
146
137
|
/** Where the wizard opens: a straight confirm for an empty folder, otherwise the fate step. */
|
|
147
138
|
export function initialStage(
|
|
148
139
|
messageCount: number,
|
package/src/lib/format.ts
CHANGED
|
@@ -19,13 +19,6 @@ export const formatNumber = (
|
|
|
19
19
|
return new Intl.NumberFormat(getLocale(), options).format(value);
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Format a number as compact (1.2K, 3.4M, etc.)
|
|
24
|
-
*/
|
|
25
|
-
export const formatCompactNumber = (value: number): string => {
|
|
26
|
-
return formatNumber(value, { notation: "compact", compactDisplay: "short" });
|
|
27
|
-
};
|
|
28
|
-
|
|
29
22
|
const EPOCH_STRING = /^-?\d+$/;
|
|
30
23
|
|
|
31
24
|
/**
|
|
@@ -156,40 +149,6 @@ export const formatEmailDate = (date: Date | string | number): string => {
|
|
|
156
149
|
return formatDate(d, { month: "short", day: "numeric", year: "numeric" });
|
|
157
150
|
};
|
|
158
151
|
|
|
159
|
-
/**
|
|
160
|
-
* Format file size in human-readable format.
|
|
161
|
-
*/
|
|
162
|
-
export const formatFileSize = (bytes: number): string => {
|
|
163
|
-
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const;
|
|
164
|
-
let unitIndex = 0;
|
|
165
|
-
let size = bytes;
|
|
166
|
-
|
|
167
|
-
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
168
|
-
size /= 1024;
|
|
169
|
-
unitIndex++;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return formatNumber(size, {
|
|
173
|
-
style: "unit",
|
|
174
|
-
unit: units[unitIndex],
|
|
175
|
-
unitDisplay: "short",
|
|
176
|
-
maximumFractionDigits: 1,
|
|
177
|
-
});
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Format a list of items (e.g., "Alice, Bob, and Carol").
|
|
182
|
-
*/
|
|
183
|
-
export const formatList = (
|
|
184
|
-
items: string[],
|
|
185
|
-
type: "conjunction" | "disjunction" = "conjunction",
|
|
186
|
-
): string => {
|
|
187
|
-
return new Intl.ListFormat(getLocale(), {
|
|
188
|
-
style: "long",
|
|
189
|
-
type,
|
|
190
|
-
}).format(items);
|
|
191
|
-
};
|
|
192
|
-
|
|
193
152
|
/**
|
|
194
153
|
* Confirmation title for the move-to-Trash delete flow. Reflects that delete
|
|
195
154
|
* moves messages to Trash (not a permanent delete) and pluralizes on count.
|
|
@@ -50,115 +50,3 @@ const extractText = (node: PlateNode): string => {
|
|
|
50
50
|
|
|
51
51
|
export const plateValueToText = (value: Value): string =>
|
|
52
52
|
value.map(extractText).join("\n");
|
|
53
|
-
|
|
54
|
-
const parseTextWithMarks = (
|
|
55
|
-
textContent: string,
|
|
56
|
-
marks: Record<string, boolean>,
|
|
57
|
-
): TText => ({
|
|
58
|
-
text: textContent,
|
|
59
|
-
...marks,
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const getMarksFromElement = (el: Element): Record<string, boolean> => {
|
|
63
|
-
const marks: Record<string, boolean> = {};
|
|
64
|
-
const tag = el.tagName.toLowerCase();
|
|
65
|
-
if (tag === "strong" || tag === "b") marks.bold = true;
|
|
66
|
-
if (tag === "em" || tag === "i") marks.italic = true;
|
|
67
|
-
return marks;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const parseInlineChildren = (
|
|
71
|
-
node: Node,
|
|
72
|
-
inheritedMarks: Record<string, boolean> = {},
|
|
73
|
-
): TText[] => {
|
|
74
|
-
const results: TText[] = [];
|
|
75
|
-
|
|
76
|
-
for (const child of Array.from(node.childNodes)) {
|
|
77
|
-
if (child.nodeType === Node.TEXT_NODE) {
|
|
78
|
-
results.push(parseTextWithMarks(child.textContent ?? "", inheritedMarks));
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
83
|
-
|
|
84
|
-
const el = child as Element;
|
|
85
|
-
const tag = el.tagName.toLowerCase();
|
|
86
|
-
|
|
87
|
-
if (tag === "a") {
|
|
88
|
-
const linkElement: TElement & { url: string } = {
|
|
89
|
-
type: "a",
|
|
90
|
-
url: el.getAttribute("href") ?? "",
|
|
91
|
-
children: parseInlineChildren(el, inheritedMarks),
|
|
92
|
-
};
|
|
93
|
-
results.push(linkElement as unknown as TText);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const marks = { ...inheritedMarks, ...getMarksFromElement(el) };
|
|
98
|
-
results.push(...parseInlineChildren(el, marks));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return results;
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const parseBlockElement = (el: Element): TElement => {
|
|
105
|
-
const tag = el.tagName.toLowerCase();
|
|
106
|
-
|
|
107
|
-
if (tag === "blockquote") {
|
|
108
|
-
const children = Array.from(el.children);
|
|
109
|
-
if (children.length > 0) {
|
|
110
|
-
return {
|
|
111
|
-
type: "blockquote",
|
|
112
|
-
children: children.map(parseBlockElement),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
const inlineChildren = parseInlineChildren(el);
|
|
116
|
-
return {
|
|
117
|
-
type: "blockquote",
|
|
118
|
-
children: inlineChildren.length > 0 ? inlineChildren : [{ text: "" }],
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (tag === "a") {
|
|
123
|
-
return {
|
|
124
|
-
type: "a",
|
|
125
|
-
url: el.getAttribute("href") ?? "",
|
|
126
|
-
children: parseInlineChildren(el),
|
|
127
|
-
} as TElement & { url: string };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const inlineChildren = parseInlineChildren(el);
|
|
131
|
-
return {
|
|
132
|
-
type: "p",
|
|
133
|
-
children: inlineChildren.length > 0 ? inlineChildren : [{ text: "" }],
|
|
134
|
-
};
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export const htmlToPlateValue = (html: string): Value => {
|
|
138
|
-
const parser = new DOMParser();
|
|
139
|
-
const doc = parser.parseFromString(html, "text/html");
|
|
140
|
-
const body = doc.body;
|
|
141
|
-
|
|
142
|
-
const blockElements = Array.from(body.childNodes).reduce<TElement[]>(
|
|
143
|
-
(acc, node) => {
|
|
144
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
145
|
-
const text = node.textContent ?? "";
|
|
146
|
-
if (text.trim()) {
|
|
147
|
-
acc.push({ type: "p", children: [{ text }] });
|
|
148
|
-
}
|
|
149
|
-
return acc;
|
|
150
|
-
}
|
|
151
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
152
|
-
acc.push(parseBlockElement(node as Element));
|
|
153
|
-
}
|
|
154
|
-
return acc;
|
|
155
|
-
},
|
|
156
|
-
[],
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
if (blockElements.length === 0) {
|
|
160
|
-
return [{ type: "p", children: [{ text: "" }] }];
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return blockElements;
|
|
164
|
-
};
|
package/src/types/index.ts
CHANGED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
messageOperationsDescribeMessageOptions,
|
|
3
|
-
messageOperationsUpdateMessageFlagsMutation,
|
|
4
|
-
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
-
import { MessageHeader } from "@remit/ui";
|
|
6
|
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
-
import { useEffect } from "react";
|
|
8
|
-
import { EmptyState } from "@/components/ui/EmptyState";
|
|
9
|
-
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
|
-
import { ErrorState } from "@/components/ui/ErrorState";
|
|
11
|
-
import {
|
|
12
|
-
formatErrorDetail,
|
|
13
|
-
isMessageNotFoundError,
|
|
14
|
-
} from "@/components/ui/error-banners";
|
|
15
|
-
import { toDisplayCategory } from "@/lib/display-category";
|
|
16
|
-
import { formatDatePreset } from "@/lib/format";
|
|
17
|
-
import { MessageBody } from "./MessageBody";
|
|
18
|
-
|
|
19
|
-
interface MessageDetailProps {
|
|
20
|
-
messageId?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const LoadingSkeleton = () => (
|
|
24
|
-
<div className="animate-pulse">
|
|
25
|
-
<div className="border-b border-line p-4">
|
|
26
|
-
<div className="h-6 bg-surface-sunken rounded w-3/4 mb-3" />
|
|
27
|
-
<div className="space-y-2">
|
|
28
|
-
<div className="h-4 bg-surface-sunken rounded w-48" />
|
|
29
|
-
<div className="h-4 bg-surface-sunken rounded w-64" />
|
|
30
|
-
<div className="h-4 bg-surface-sunken rounded w-40" />
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
33
|
-
<div className="p-4 space-y-2">
|
|
34
|
-
<div className="h-4 bg-surface-sunken rounded w-full" />
|
|
35
|
-
<div className="h-4 bg-surface-sunken rounded w-full" />
|
|
36
|
-
<div className="h-4 bg-surface-sunken rounded w-3/4" />
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
export const MessageDetail = ({ messageId }: MessageDetailProps) => {
|
|
42
|
-
const queryClient = useQueryClient();
|
|
43
|
-
const { pushError } = useErrorBanners();
|
|
44
|
-
|
|
45
|
-
const {
|
|
46
|
-
data: messageData,
|
|
47
|
-
isLoading,
|
|
48
|
-
isError,
|
|
49
|
-
error,
|
|
50
|
-
refetch,
|
|
51
|
-
} = useQuery({
|
|
52
|
-
...messageOperationsDescribeMessageOptions({
|
|
53
|
-
path: { messageId: messageId ?? "" },
|
|
54
|
-
}),
|
|
55
|
-
enabled: !!messageId,
|
|
56
|
-
// A 404 (row deleted / mid-refresh) renders the inline "deleted" empty
|
|
57
|
-
// state below — opt it out of the global fatal overlay. A 5xx still
|
|
58
|
-
// escalates globally (meta.softError is ignored for 5xx — #1059).
|
|
59
|
-
meta: { softError: true },
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const updateFlags = useMutation({
|
|
63
|
-
...messageOperationsUpdateMessageFlagsMutation(),
|
|
64
|
-
onSuccess: () => {
|
|
65
|
-
queryClient.invalidateQueries({
|
|
66
|
-
predicate: (query) =>
|
|
67
|
-
query.queryKey[0] !== null &&
|
|
68
|
-
typeof query.queryKey[0] === "object" &&
|
|
69
|
-
"_id" in query.queryKey[0] &&
|
|
70
|
-
query.queryKey[0]._id === "threadOperationsListThreads",
|
|
71
|
-
});
|
|
72
|
-
},
|
|
73
|
-
onError: (error) => {
|
|
74
|
-
pushError({
|
|
75
|
-
title: "Couldn't mark message as read",
|
|
76
|
-
detail: formatErrorDetail(error),
|
|
77
|
-
error,
|
|
78
|
-
});
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (messageId && messageData && !messageData.flags.includes("\\Seen")) {
|
|
84
|
-
updateFlags.mutate({
|
|
85
|
-
path: { messageId },
|
|
86
|
-
body: { isRead: true },
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}, [messageId, messageData, updateFlags.mutate]);
|
|
90
|
-
|
|
91
|
-
if (!messageId) {
|
|
92
|
-
return (
|
|
93
|
-
<div className="flex h-full items-center justify-center">
|
|
94
|
-
<EmptyState message="Select a message to read" />
|
|
95
|
-
</div>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (isLoading) {
|
|
100
|
-
return <LoadingSkeleton />;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (isError) {
|
|
104
|
-
if (isMessageNotFoundError(error)) {
|
|
105
|
-
return (
|
|
106
|
-
<div className="flex h-full items-center justify-center">
|
|
107
|
-
<EmptyState message="This message has been deleted" />
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return (
|
|
112
|
-
<div className="flex h-full items-center justify-center">
|
|
113
|
-
<ErrorState
|
|
114
|
-
title="Couldn't load this message"
|
|
115
|
-
error={error}
|
|
116
|
-
onRetry={() => refetch()}
|
|
117
|
-
/>
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!messageData) {
|
|
123
|
-
return (
|
|
124
|
-
<div className="flex h-full items-center justify-center">
|
|
125
|
-
<EmptyState message="Message not found" />
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const fromAddress = messageData.envelope.from[0];
|
|
131
|
-
const isTrusted = fromAddress?.flags?.trusted?.value === true;
|
|
132
|
-
|
|
133
|
-
return (
|
|
134
|
-
<article>
|
|
135
|
-
<MessageHeader
|
|
136
|
-
subject={messageData.envelope.subject}
|
|
137
|
-
from={messageData.envelope.from}
|
|
138
|
-
to={messageData.envelope.to}
|
|
139
|
-
cc={messageData.envelope.cc}
|
|
140
|
-
date={formatDatePreset(messageData.envelope.date, "full")}
|
|
141
|
-
category={toDisplayCategory(messageData.envelope.category)}
|
|
142
|
-
senderTrust={messageData.envelope.senderTrust}
|
|
143
|
-
/>
|
|
144
|
-
<MessageBody
|
|
145
|
-
bodyParts={messageData.bodyParts}
|
|
146
|
-
messageId={messageId}
|
|
147
|
-
fromAddressId={fromAddress?.addressId}
|
|
148
|
-
isTrusted={isTrusted}
|
|
149
|
-
category={toDisplayCategory(messageData.envelope.category)}
|
|
150
|
-
className="p-4"
|
|
151
|
-
/>
|
|
152
|
-
</article>
|
|
153
|
-
);
|
|
154
|
-
};
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
export const paginationSchema = z.object({
|
|
4
|
-
page: z.number().int().positive().default(1),
|
|
5
|
-
limit: z.number().int().min(10).max(100).default(50),
|
|
6
|
-
cursor: z.string().optional(),
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
export const mailListSearchSchema = z.object({
|
|
10
|
-
filter: z.enum(["all", "unread", "starred", "attachments"]).default("all"),
|
|
11
|
-
sort: z.enum(["date", "sender", "subject"]).default("date"),
|
|
12
|
-
order: z.enum(["asc", "desc"]).default("desc"),
|
|
13
|
-
|
|
14
|
-
...paginationSchema.shape,
|
|
15
|
-
|
|
16
|
-
dialog: z.enum(["compose", "move", "delete", "settings"]).optional(),
|
|
17
|
-
|
|
18
|
-
selectedThreadId: z.string().optional(),
|
|
19
|
-
selectedMessageId: z.string().optional(),
|
|
20
|
-
|
|
21
|
-
q: z.string().optional(),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export type MailListSearch = z.infer<typeof mailListSearchSchema>;
|
|
25
|
-
|
|
26
|
-
export const threadViewSearchSchema = z.object({
|
|
27
|
-
expandedMessageId: z.string().optional(),
|
|
28
|
-
expandAll: z.boolean().default(false),
|
|
29
|
-
|
|
30
|
-
dialog: z.enum(["reply", "reply-all", "forward", "delete"]).optional(),
|
|
31
|
-
replyToMessageId: z.string().optional(),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export type ThreadViewSearch = z.infer<typeof threadViewSearchSchema>;
|