@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.88",
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
@@ -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
- };
@@ -65,9 +65,3 @@ export interface Account {
65
65
  createdAt: string;
66
66
  updatedAt: string;
67
67
  }
68
-
69
- export interface PaginatedResponse<T> {
70
- data: T[];
71
- nextCursor?: string;
72
- hasMore: boolean;
73
- }
@@ -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>;