@remit/web-client 0.0.48 → 0.0.50

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.48",
3
+ "version": "0.0.50",
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": {
@@ -97,8 +97,6 @@
97
97
  "@aws-sdk/client-dynamodb": "*",
98
98
  "@aws-sdk/client-sqs": "*",
99
99
  "@aws-sdk/lib-dynamodb": "*",
100
- "@types/nodemailer": "*",
101
- "nodemailer": "^9.0.3",
102
100
  "@playwright/test": "1.58.2",
103
101
  "@remit/drizzle-service": "*",
104
102
  "@remit/electrodb-entities": "*",
@@ -107,11 +105,15 @@
107
105
  "@tailwindcss/vite": "^4",
108
106
  "@tanstack/router-generator": "^1",
109
107
  "@tanstack/router-plugin": "^1",
108
+ "@types/jsdom": "^28.0.3",
109
+ "@types/nodemailer": "*",
110
110
  "@types/pngjs": "^6.0.5",
111
111
  "@vitejs/plugin-react": "^4",
112
112
  "electrodb": "*",
113
113
  "esbuild": "*",
114
114
  "expect-env": "*",
115
+ "jsdom": "^29.1.1",
116
+ "nodemailer": "^9.0.3",
115
117
  "pngjs": "^5",
116
118
  "react": "^19",
117
119
  "react-dom": "^19.2.4",
@@ -33,6 +33,14 @@ import { ConversationView } from "@/components/mail/ConversationView";
33
33
  import { DailyBrief } from "@/components/mail/DailyBrief";
34
34
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
35
35
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
36
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
37
+ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
38
+ import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
39
+ import {
40
+ type TriageContext,
41
+ useTriageContext,
42
+ useTriageLayer,
43
+ } from "@/hooks/useTriageLayer";
36
44
  import {
37
45
  buildConversationTarget,
38
46
  type ConversationTarget,
@@ -51,6 +59,17 @@ interface BriefPaneContextValue {
51
59
  onSelectMessage: (id: string) => void;
52
60
  onSelectSearchResult: (result: SearchResult) => void;
53
61
  onCloseThread: () => void;
62
+ /**
63
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
64
+ * account — the brief spans accounts, so there is no route mailbox to key by.
65
+ */
66
+ actions: ThreadActions;
67
+ /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
68
+ triage: TriageContext;
69
+ onDeleteMessages: (messageIds: string[]) => void;
70
+ onMarkMessagesRead: (messageIds: string[]) => void;
71
+ nextMessageId: string | undefined;
72
+ previousMessageId: string | undefined;
54
73
  }
55
74
 
56
75
  const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
@@ -136,6 +155,28 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
136
155
  [navigate, searchInput],
137
156
  );
138
157
 
158
+ const handleDeselectIfRemoved = useCallback(
159
+ (removedIds: string[]) => {
160
+ if (!selectedMessageId) return;
161
+ if (!removedIds.includes(selectedMessageId)) return;
162
+ navigate({
163
+ to: "/mail",
164
+ search: (prev) => ({
165
+ ...prev,
166
+ selectedMessageId: undefined,
167
+ selectedThreadId: undefined,
168
+ selectedMailboxId: undefined,
169
+ }),
170
+ });
171
+ },
172
+ [selectedMessageId, navigate],
173
+ );
174
+
175
+ const actions = useThreadActions({
176
+ thread: selectedThread,
177
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
178
+ });
179
+
139
180
  const handleCloseThread = useCallback(() => {
140
181
  navigate({
141
182
  to: "/mail",
@@ -148,6 +189,63 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
148
189
  });
149
190
  }, [navigate]);
150
191
 
192
+ const triage = useTriageContext();
193
+
194
+ // A brief selection spans accounts and mailboxes, so the listings these
195
+ // patch are resolved from each message's own mailbox — the open thread's is
196
+ // only the fallback.
197
+ const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
198
+ const { deleteMessages } = useDeleteMessages({
199
+ mailboxId: selectedThread?.mailboxId ?? "",
200
+ messages: briefThreads,
201
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
202
+ });
203
+ const { toggleReadFor } = useToggleReadFor({
204
+ mailboxId: selectedThread?.mailboxId ?? "",
205
+ messages: briefThreads,
206
+ });
207
+ const handleMarkMessagesRead = useCallback(
208
+ (messageIds: string[]) => toggleReadFor(messageIds, true),
209
+ [toggleReadFor],
210
+ );
211
+
212
+ const focusedThreadId = triage.focusedMessageId;
213
+ const focusedThread = useMemo(
214
+ () => threadsData?.items.find((t) => t.messageId === focusedThreadId),
215
+ [threadsData, focusedThreadId],
216
+ );
217
+ const triageTarget = focusedThread ?? selectedThread;
218
+ const triageActions = useThreadActions({ thread: triageTarget });
219
+
220
+ const { nextMessageId, previousMessageId } = useTriageLayer({
221
+ context: triage,
222
+ orderedIds: triage.orderedIds,
223
+ selectedMessageId,
224
+ onClose: handleCloseThread,
225
+ handlers: {
226
+ reply: () => actions.requestCompose("reply"),
227
+ replyAll: () => actions.requestCompose("reply_all"),
228
+ forward: () => actions.requestCompose("forward"),
229
+ delete: () => {
230
+ if (triage.listCommandsRef.current?.requestDelete()) return;
231
+ triageActions.deleteThread();
232
+ },
233
+ toggleStar: triageActions.toggleStar,
234
+ toggleRead: () => {
235
+ const ids =
236
+ triage.selectedIds.length > 0
237
+ ? triage.selectedIds
238
+ : triageTarget
239
+ ? [triageTarget.messageId]
240
+ : [];
241
+ if (ids.length === 0) return;
242
+ toggleReadFor(ids, !(triageTarget?.isRead ?? false));
243
+ },
244
+ goFlagged: () => navigate({ to: "/mail/flagged" }),
245
+ goSettings: () => navigate({ to: "/settings" }),
246
+ },
247
+ });
248
+
151
249
  const ctx: BriefPaneContextValue = {
152
250
  selectedMessageId,
153
251
  selectedThread,
@@ -155,6 +253,12 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
155
253
  onSelectMessage: handleSelectMessage,
156
254
  onSelectSearchResult: handleSelectSearchResult,
157
255
  onCloseThread: handleCloseThread,
256
+ actions,
257
+ triage,
258
+ onDeleteMessages: deleteMessages,
259
+ onMarkMessagesRead: handleMarkMessagesRead,
260
+ nextMessageId,
261
+ previousMessageId,
158
262
  };
159
263
 
160
264
  return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
@@ -168,8 +272,14 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
168
272
  * Daily brief list. Mount in the `list` slot of `AppShellSlotted`.
169
273
  */
170
274
  function BriefList() {
171
- const { selectedMessageId, onSelectMessage, onSelectSearchResult } =
172
- useBriefPane();
275
+ const {
276
+ selectedMessageId,
277
+ onSelectMessage,
278
+ onSelectSearchResult,
279
+ triage,
280
+ onDeleteMessages,
281
+ onMarkMessagesRead,
282
+ } = useBriefPane();
173
283
  const { accounts } = useMailContext();
174
284
 
175
285
  return (
@@ -178,6 +288,10 @@ function BriefList() {
178
288
  selectedMessageId={selectedMessageId}
179
289
  onSelectMessage={onSelectMessage}
180
290
  onSelectSearchResult={onSelectSearchResult}
291
+ commandsRef={triage.listCommandsRef}
292
+ onTriageContextChange={triage.onTriageContextChange}
293
+ onDeleteMessages={onDeleteMessages}
294
+ onMarkMessagesRead={onMarkMessagesRead}
181
295
  />
182
296
  );
183
297
  }
@@ -187,7 +301,7 @@ function BriefList() {
187
301
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
188
302
  */
189
303
  function BriefReading() {
190
- const { conversation } = useBriefPane();
304
+ const { conversation, actions } = useBriefPane();
191
305
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
306
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
307
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -203,6 +317,25 @@ function BriefReading() {
203
317
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
318
  canToggleIntelligence={canToggleIntelligence}
205
319
  onToggleIntelligence={onToggleIntelligence}
320
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
321
+ onReplyAll={
322
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
323
+ }
324
+ onForward={
325
+ hasThread ? () => actions.requestCompose("forward") : undefined
326
+ }
327
+ onDelete={hasThread ? actions.deleteThread : undefined}
328
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
329
+ isStarred={actions.isStarred}
330
+ moveContext={
331
+ hasThread && actions.accountId && actions.mailboxId
332
+ ? {
333
+ accountId: actions.accountId,
334
+ currentMailboxId: actions.mailboxId,
335
+ onMove: actions.moveThread,
336
+ }
337
+ : undefined
338
+ }
206
339
  />
207
340
  <div className="min-h-0 flex-1 overflow-hidden">
208
341
  {conversation ? (
@@ -212,6 +345,8 @@ function BriefReading() {
212
345
  subject={conversation.subject}
213
346
  selectedMessageId={conversation.messageId}
214
347
  authenticity={conversation.authenticity}
348
+ composeRequest={actions.composeRequest}
349
+ onComposeClose={actions.clearComposeRequest}
215
350
  />
216
351
  ) : (
217
352
  <ReadingPaneEmpty />
@@ -250,6 +385,8 @@ function BriefPhone() {
250
385
  onSelectMessage,
251
386
  onSelectSearchResult,
252
387
  onCloseThread,
388
+ nextMessageId,
389
+ previousMessageId,
253
390
  } = useBriefPane();
254
391
  const { accounts, intelligenceOpen, onToggleIntelligence } = useMailContext();
255
392
 
@@ -264,6 +401,15 @@ function BriefPhone() {
264
401
  authenticity={conversation.authenticity}
265
402
  onBack={onCloseThread}
266
403
  onOpenIntelligence={onToggleIntelligence}
404
+ onSwipeNext={
405
+ nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
406
+ }
407
+ onSwipePrevious={
408
+ previousMessageId
409
+ ? () => onSelectMessage(previousMessageId)
410
+ : undefined
411
+ }
412
+ mobileIntelligenceOpen={intelligenceOpen}
267
413
  />
268
414
  <Drawer
269
415
  isOpen={intelligenceOpen}
@@ -34,10 +34,11 @@ import {
34
34
  import { useQueries, useQuery } from "@tanstack/react-query";
35
35
  import { useNavigate } from "@tanstack/react-router";
36
36
  import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
37
- import { useCallback, useMemo, useState } from "react";
37
+ import { type RefObject, useCallback, useMemo, useState } from "react";
38
38
  import { useIsDesktop } from "@/hooks/useMediaQuery";
39
39
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
40
40
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
41
+ import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
41
42
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
42
43
  import {
43
44
  groupBriefSections,
@@ -51,7 +52,12 @@ import { useMailContext } from "@/lib/mail-context";
51
52
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
52
53
  import { parseSearchTokens } from "@/lib/search-tokens";
53
54
  import { MailListHeader } from "./MailListHeader";
55
+ import type { MessageListCommands } from "./MessageList";
54
56
  import { MessageRow } from "./MessageRow";
57
+ import {
58
+ ThreadListInteraction,
59
+ ThreadListSelectionBar,
60
+ } from "./ThreadListInteraction";
55
61
 
56
62
  /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
57
63
  ids) so the phone search takeover narrows results the same way the list does. */
@@ -134,6 +140,12 @@ interface DailyBriefProps {
134
140
  * so it opens even when its message isn't in the loaded brief list.
135
141
  */
136
142
  onSelectSearchResult?: (result: SearchResult) => void;
143
+ /** Where the list publishes the commands the keyboard layer drives. */
144
+ commandsRef?: RefObject<MessageListCommands | null>;
145
+ /** Cursor / selection / display order, reported up to the triage layer. */
146
+ onTriageContextChange?: (context: TriageContextUpdate) => void;
147
+ onDeleteMessages?: (messageIds: string[]) => void;
148
+ onMarkMessagesRead?: (messageIds: string[]) => void;
137
149
  }
138
150
 
139
151
  export function DailyBrief({
@@ -141,6 +153,10 @@ export function DailyBrief({
141
153
  selectedMessageId,
142
154
  onSelectMessage,
143
155
  onSelectSearchResult,
156
+ commandsRef,
157
+ onTriageContextChange,
158
+ onDeleteMessages,
159
+ onMarkMessagesRead,
144
160
  }: DailyBriefProps) {
145
161
  const { searchQuery, resultFolderIndex } = useMailContext();
146
162
  const tokenContext = useSearchTokenContext();
@@ -405,17 +421,33 @@ export function DailyBrief({
405
421
  <p className="text-xs text-fg-subtle">Nothing needs attention.</p>
406
422
  </div>
407
423
  ) : (
408
- <BriefSections
409
- sections={sections}
410
- Row={MessageRow}
411
- briefCategory={selectedCategory}
412
- onSelectBriefCategory={setSelectedCategory}
413
- sources={accountSources}
414
- sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
415
- onSelectSource={setSelectedAccountId}
416
- selectedThreadId={selectedMessageId}
417
- onSelectThread={onSelectMessage}
418
- />
424
+ <ThreadListInteraction
425
+ selectedMessageId={selectedMessageId}
426
+ onOpen={(id) => onSelectMessage?.(id)}
427
+ onDeleteMessages={onDeleteMessages}
428
+ commandsRef={commandsRef}
429
+ onTriageContextChange={onTriageContextChange}
430
+ >
431
+ <div className="flex h-full min-h-0 flex-col">
432
+ {onDeleteMessages ? (
433
+ <ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
434
+ ) : null}
435
+ <div className="min-h-0 flex-1">
436
+ <BriefSections
437
+ sections={sections}
438
+ Row={MessageRow}
439
+ manageFocus={false}
440
+ briefCategory={selectedCategory}
441
+ onSelectBriefCategory={setSelectedCategory}
442
+ sources={accountSources}
443
+ sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
444
+ onSelectSource={setSelectedAccountId}
445
+ selectedThreadId={selectedMessageId}
446
+ onSelectThread={onSelectMessage}
447
+ />
448
+ </div>
449
+ </div>
450
+ </ThreadListInteraction>
419
451
  );
420
452
 
421
453
  return (
@@ -15,16 +15,15 @@
15
15
  */
16
16
  import {
17
17
  flaggedFilterConfig,
18
- LIST_ROW_SELECTOR,
19
18
  MessageListPane,
20
19
  type ThreadRowData,
21
- useRovingFocus,
22
20
  } from "@remit/ui";
23
- import { useCallback, useMemo, useRef, useState } from "react";
21
+ import { type RefObject, useCallback, useMemo, useState } from "react";
24
22
  import { formatErrorMessage } from "@/components/ui/ErrorState";
25
23
  import { useIsDesktop } from "@/hooks/useMediaQuery";
26
24
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
27
25
  import { useStarredThreads } from "@/hooks/useStarredThreads";
26
+ import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
28
27
  import {
29
28
  matchesBriefSearch,
30
29
  matchesSearchTokens,
@@ -36,7 +35,12 @@ import { rowToSearchResult } from "@/lib/search-result";
36
35
  import { parseSearchTokens } from "@/lib/search-tokens";
37
36
  import { dedupeByThread } from "@/lib/starred-rows";
38
37
  import { MailViewChrome } from "./MailViewChrome";
38
+ import type { MessageListCommands } from "./MessageList";
39
39
  import { MessageRow } from "./MessageRow";
40
+ import {
41
+ ThreadListInteraction,
42
+ ThreadListSelectionBar,
43
+ } from "./ThreadListInteraction";
40
44
 
41
45
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
42
46
  unread: (t) => !t.isRead,
@@ -46,19 +50,26 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
46
50
  interface FlaggedListProps {
47
51
  selectedMessageId?: string;
48
52
  onSelectMessage?: (id: string) => void;
53
+ /** Where the list publishes the commands the keyboard layer drives. */
54
+ commandsRef?: RefObject<MessageListCommands | null>;
55
+ /** Cursor / selection / display order, reported up to the triage layer. */
56
+ onTriageContextChange?: (context: TriageContextUpdate) => void;
57
+ onDeleteMessages?: (messageIds: string[]) => void;
58
+ onMarkMessagesRead?: (messageIds: string[]) => void;
49
59
  }
50
60
 
51
61
  export function FlaggedList({
52
62
  selectedMessageId,
53
63
  onSelectMessage,
64
+ commandsRef,
65
+ onTriageContextChange,
66
+ onDeleteMessages,
67
+ onMarkMessagesRead,
54
68
  }: FlaggedListProps) {
55
69
  const { searchQuery, resultFolderIndex } = useMailContext();
56
70
  const tokenContext = useSearchTokenContext();
57
71
  const isDesktop = useIsDesktop();
58
72
 
59
- const listRef = useRef<HTMLDivElement>(null);
60
- useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
61
-
62
73
  const [selectedCategory, setSelectedCategory] = useState("all");
63
74
  const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
64
75
  new Set(),
@@ -135,28 +146,41 @@ export function FlaggedList({
135
146
  }, []);
136
147
 
137
148
  const listBody = (
138
- <div ref={listRef} className="flex-1 overflow-y-auto">
139
- <div className="divide-y divide-line">
140
- {rows.map((thread) => (
141
- <MessageRow
142
- key={thread.id}
143
- thread={thread}
144
- active={thread.id === selectedMessageId}
145
- onClick={() => onSelectMessage?.(thread.id)}
146
- />
147
- ))}
149
+ <ThreadListInteraction
150
+ selectedMessageId={selectedMessageId}
151
+ onOpen={(id) => onSelectMessage?.(id)}
152
+ onDeleteMessages={onDeleteMessages}
153
+ commandsRef={commandsRef}
154
+ onTriageContextChange={onTriageContextChange}
155
+ >
156
+ <div className="flex h-full min-h-0 flex-col">
157
+ {onDeleteMessages ? (
158
+ <ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
159
+ ) : null}
160
+ <div className="flex-1 overflow-y-auto">
161
+ <div className="divide-y divide-line">
162
+ {rows.map((thread) => (
163
+ <MessageRow
164
+ key={thread.id}
165
+ thread={thread}
166
+ active={thread.id === selectedMessageId}
167
+ onClick={() => onSelectMessage?.(thread.id)}
168
+ />
169
+ ))}
170
+ </div>
171
+ {hasNextPage ? (
172
+ <button
173
+ type="button"
174
+ className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
175
+ onClick={() => fetchNextPage()}
176
+ disabled={isFetchingNextPage}
177
+ >
178
+ {isFetchingNextPage ? "Loading…" : "Load more"}
179
+ </button>
180
+ ) : null}
181
+ </div>
148
182
  </div>
149
- {hasNextPage ? (
150
- <button
151
- type="button"
152
- className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
153
- onClick={() => fetchNextPage()}
154
- disabled={isFetchingNextPage}
155
- >
156
- {isFetchingNextPage ? "Loading…" : "Load more"}
157
- </button>
158
- ) : null}
159
- </div>
183
+ </ThreadListInteraction>
160
184
  );
161
185
 
162
186
  return (