@remit/web-client 0.0.48 → 0.0.49

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.49",
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": {
@@ -33,6 +33,7 @@ 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 { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
36
37
  import {
37
38
  buildConversationTarget,
38
39
  type ConversationTarget,
@@ -51,6 +52,11 @@ interface BriefPaneContextValue {
51
52
  onSelectMessage: (id: string) => void;
52
53
  onSelectSearchResult: (result: SearchResult) => void;
53
54
  onCloseThread: () => void;
55
+ /**
56
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
57
+ * account — the brief spans accounts, so there is no route mailbox to key by.
58
+ */
59
+ actions: ThreadActions;
54
60
  }
55
61
 
56
62
  const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
@@ -136,6 +142,28 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
136
142
  [navigate, searchInput],
137
143
  );
138
144
 
145
+ const handleDeselectIfRemoved = useCallback(
146
+ (removedIds: string[]) => {
147
+ if (!selectedMessageId) return;
148
+ if (!removedIds.includes(selectedMessageId)) return;
149
+ navigate({
150
+ to: "/mail",
151
+ search: (prev) => ({
152
+ ...prev,
153
+ selectedMessageId: undefined,
154
+ selectedThreadId: undefined,
155
+ selectedMailboxId: undefined,
156
+ }),
157
+ });
158
+ },
159
+ [selectedMessageId, navigate],
160
+ );
161
+
162
+ const actions = useThreadActions({
163
+ thread: selectedThread,
164
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
165
+ });
166
+
139
167
  const handleCloseThread = useCallback(() => {
140
168
  navigate({
141
169
  to: "/mail",
@@ -155,6 +183,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
155
183
  onSelectMessage: handleSelectMessage,
156
184
  onSelectSearchResult: handleSelectSearchResult,
157
185
  onCloseThread: handleCloseThread,
186
+ actions,
158
187
  };
159
188
 
160
189
  return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
@@ -187,7 +216,7 @@ function BriefList() {
187
216
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
188
217
  */
189
218
  function BriefReading() {
190
- const { conversation } = useBriefPane();
219
+ const { conversation, actions } = useBriefPane();
191
220
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
221
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
222
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -203,6 +232,25 @@ function BriefReading() {
203
232
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
233
  canToggleIntelligence={canToggleIntelligence}
205
234
  onToggleIntelligence={onToggleIntelligence}
235
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
236
+ onReplyAll={
237
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
238
+ }
239
+ onForward={
240
+ hasThread ? () => actions.requestCompose("forward") : undefined
241
+ }
242
+ onDelete={hasThread ? actions.deleteThread : undefined}
243
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
244
+ isStarred={actions.isStarred}
245
+ moveContext={
246
+ hasThread && actions.accountId && actions.mailboxId
247
+ ? {
248
+ accountId: actions.accountId,
249
+ currentMailboxId: actions.mailboxId,
250
+ onMove: actions.moveThread,
251
+ }
252
+ : undefined
253
+ }
206
254
  />
207
255
  <div className="min-h-0 flex-1 overflow-hidden">
208
256
  {conversation ? (
@@ -212,6 +260,8 @@ function BriefReading() {
212
260
  subject={conversation.subject}
213
261
  selectedMessageId={conversation.messageId}
214
262
  authenticity={conversation.authenticity}
263
+ composeRequest={actions.composeRequest}
264
+ onComposeClose={actions.clearComposeRequest}
215
265
  />
216
266
  ) : (
217
267
  <ReadingPaneEmpty />
@@ -36,6 +36,7 @@ import { FlaggedList } from "@/components/mail/FlaggedList";
36
36
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
37
37
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
38
38
  import { useStarredThreads } from "@/hooks/useStarredThreads";
39
+ import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
39
40
  import { useMailContext } from "@/lib/mail-context";
40
41
 
41
42
  /* ------------------------------------------------------------------ */
@@ -47,6 +48,11 @@ interface FlaggedPaneContextValue {
47
48
  selectedThread: RemitImapThreadMessageResponse | undefined;
48
49
  onSelectMessage: (id: string) => void;
49
50
  onCloseThread: () => void;
51
+ /**
52
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
53
+ * account — Flagged spans accounts, so there is no route mailbox to key by.
54
+ */
55
+ actions: ThreadActions;
50
56
  }
51
57
 
52
58
  const FlaggedPaneCtx = createContext<FlaggedPaneContextValue | null>(null);
@@ -96,11 +102,26 @@ function FlaggedPaneProvider({
96
102
  });
97
103
  }, [navigate]);
98
104
 
105
+ const handleDeselectIfRemoved = useCallback(
106
+ (removedIds: string[]) => {
107
+ if (!selectedMessageId) return;
108
+ if (!removedIds.includes(selectedMessageId)) return;
109
+ handleCloseThread();
110
+ },
111
+ [selectedMessageId, handleCloseThread],
112
+ );
113
+
114
+ const actions = useThreadActions({
115
+ thread: selectedThread,
116
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
117
+ });
118
+
99
119
  const ctx: FlaggedPaneContextValue = {
100
120
  selectedMessageId,
101
121
  selectedThread,
102
122
  onSelectMessage: handleSelectMessage,
103
123
  onCloseThread: handleCloseThread,
124
+ actions,
104
125
  };
105
126
 
106
127
  return (
@@ -128,7 +149,7 @@ function FlaggedListSlot() {
128
149
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
129
150
  */
130
151
  function FlaggedReading() {
131
- const { selectedThread } = useFlaggedPane();
152
+ const { selectedThread, actions } = useFlaggedPane();
132
153
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
133
154
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
134
155
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -144,6 +165,25 @@ function FlaggedReading() {
144
165
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
145
166
  canToggleIntelligence={canToggleIntelligence}
146
167
  onToggleIntelligence={onToggleIntelligence}
168
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
169
+ onReplyAll={
170
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
171
+ }
172
+ onForward={
173
+ hasThread ? () => actions.requestCompose("forward") : undefined
174
+ }
175
+ onDelete={hasThread ? actions.deleteThread : undefined}
176
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
177
+ isStarred={actions.isStarred}
178
+ moveContext={
179
+ hasThread && actions.accountId && actions.mailboxId
180
+ ? {
181
+ accountId: actions.accountId,
182
+ currentMailboxId: actions.mailboxId,
183
+ onMove: actions.moveThread,
184
+ }
185
+ : undefined
186
+ }
147
187
  />
148
188
  <div className="min-h-0 flex-1 overflow-hidden">
149
189
  {selectedThread ? (
@@ -152,6 +192,8 @@ function FlaggedReading() {
152
192
  mailboxId={selectedThread.mailboxId}
153
193
  subject={selectedThread.subject}
154
194
  authenticity={selectedThread.authenticity}
195
+ composeRequest={actions.composeRequest}
196
+ onComposeClose={actions.clearComposeRequest}
155
197
  />
156
198
  ) : (
157
199
  <ReadingPaneEmpty />
@@ -18,7 +18,6 @@
18
18
  import {
19
19
  outboxDetailOperationsDeleteOutboxMessageMutation,
20
20
  outboxOperationsListOutboxMessagesQueryKey,
21
- threadDetailOperationsListThreadMessagesQueryKey,
22
21
  threadOperationsListThreadsQueryKey,
23
22
  threadOperationsSearchThreadsQueryKey,
24
23
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
@@ -91,6 +90,8 @@ import { useMoveMessages } from "@/hooks/useMoveMessages";
91
90
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
92
91
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
93
92
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
93
+ import { useThreadActions } from "@/hooks/useThreadActions";
94
+ import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
94
95
  import { useToggleStar } from "@/hooks/useToggleStar";
95
96
  import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
96
97
  import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
@@ -503,15 +504,9 @@ function MailboxPaneProvider({
503
504
  const queryClient = useQueryClient();
504
505
  const { pushError } = useErrorBanners();
505
506
 
506
- const { deleteMessages: toolbarDelete } = useDeleteMessages({
507
+ const toolbarActions = useThreadActions({
508
+ thread: selectedThread,
507
509
  mailboxId,
508
- threadId: selectedThread?.threadId,
509
- onAfterOptimisticRemove: handleDeselectIfRemoved,
510
- });
511
-
512
- const { moveMessages: toolbarMove } = useMoveMessages({
513
- mailboxId,
514
- threadId: selectedThread?.threadId,
515
510
  accountId: mailboxAccountId,
516
511
  onAfterOptimisticRemove: handleDeselectIfRemoved,
517
512
  });
@@ -522,63 +517,19 @@ function MailboxPaneProvider({
522
517
 
523
518
  const { archiveMailboxId } = useArchiveMailbox(mailboxAccountId);
524
519
 
525
- // Get thread message ids from cache; fall back to representative message id.
526
- const getThreadMessageIds = useCallback(
527
- (thread: RemitImapThreadMessageResponse) => {
528
- const threadKey = threadDetailOperationsListThreadMessagesQueryKey({
529
- path: { threadId: thread.threadId },
530
- });
531
- const cached = queryClient.getQueriesData<{
532
- items: { messageId: string }[];
533
- }>({ queryKey: threadKey });
534
- const ids = cached.flatMap(
535
- ([, data]) => data?.items.map((m) => m.messageId) ?? [],
536
- );
537
- return ids.length > 0 ? ids : [thread.messageId];
538
- },
539
- [queryClient],
540
- );
541
-
542
- const handleToolbarDelete = useCallback(() => {
543
- if (!selectedThread) return;
544
- const messageIds = getThreadMessageIds(selectedThread);
545
- toolbarDelete(messageIds);
546
- }, [selectedThread, getThreadMessageIds, toolbarDelete]);
547
-
548
- const handleToolbarMove = useCallback(
549
- (destMailboxId: string) => {
550
- if (!selectedThread) return;
551
- const messageIds = getThreadMessageIds(selectedThread);
552
- toolbarMove(messageIds, destMailboxId);
553
- },
554
- [selectedThread, getThreadMessageIds, toolbarMove],
555
- );
556
-
557
- const { toggleStar: toolbarToggleStar } = useToggleStar({
558
- threadId: selectedThread?.threadId ?? "",
559
- mailboxId,
560
- });
561
-
562
- const handleToolbarStar = useCallback(() => {
563
- if (!selectedThread) return;
564
- toolbarToggleStar(selectedThread.messageId, selectedThread.hasStars);
565
- }, [selectedThread, toolbarToggleStar]);
520
+ const getThreadMessageIds = useThreadMessageIds();
566
521
 
567
- const [toolbarComposeRequest, setToolbarComposeRequest] =
568
- useState<ComposeMode | null>(null);
522
+ const { requestCompose: setToolbarComposeRequest } = toolbarActions;
569
523
 
570
524
  const handleToolbarReply = useCallback(() => {
571
525
  setToolbarComposeRequest("reply");
572
- }, []);
526
+ }, [setToolbarComposeRequest]);
573
527
  const handleToolbarReplyAll = useCallback(() => {
574
528
  setToolbarComposeRequest("reply_all");
575
- }, []);
529
+ }, [setToolbarComposeRequest]);
576
530
  const handleToolbarForward = useCallback(() => {
577
531
  setToolbarComposeRequest("forward");
578
- }, []);
579
- const handleClearComposeRequest = useCallback(() => {
580
- setToolbarComposeRequest(null);
581
- }, []);
532
+ }, [setToolbarComposeRequest]);
582
533
 
583
534
  const { state: composeState, openCompose, closeCompose } = useCompose();
584
535
 
@@ -688,15 +639,15 @@ function MailboxPaneProvider({
688
639
  const triageReply = useCallback(() => {
689
640
  if (ensureFocusedOpen()) return;
690
641
  if (selectedThread) setToolbarComposeRequest("reply");
691
- }, [ensureFocusedOpen, selectedThread]);
642
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
692
643
  const triageReplyAll = useCallback(() => {
693
644
  if (ensureFocusedOpen()) return;
694
645
  if (selectedThread) setToolbarComposeRequest("reply_all");
695
- }, [ensureFocusedOpen, selectedThread]);
646
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
696
647
  const triageForward = useCallback(() => {
697
648
  if (ensureFocusedOpen()) return;
698
649
  if (selectedThread) setToolbarComposeRequest("forward");
699
- }, [ensureFocusedOpen, selectedThread]);
650
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
700
651
 
701
652
  const triageTargetMessageIds = useCallback((): string[] => {
702
653
  if (triageSelectedIds.length > 0) return triageSelectedIds;
@@ -889,15 +840,15 @@ function MailboxPaneProvider({
889
840
  onTriageContextChange: handleTriageContextChange,
890
841
  listCommandsRef,
891
842
  onRetry: () => refetch(),
892
- toolbarComposeRequest,
843
+ toolbarComposeRequest: toolbarActions.composeRequest,
893
844
  onToolbarReply: handleToolbarReply,
894
845
  onToolbarReplyAll: handleToolbarReplyAll,
895
846
  onToolbarForward: handleToolbarForward,
896
- onClearComposeRequest: handleClearComposeRequest,
897
- onToolbarDelete: handleToolbarDelete,
898
- onToolbarStar: handleToolbarStar,
847
+ onClearComposeRequest: toolbarActions.clearComposeRequest,
848
+ onToolbarDelete: toolbarActions.deleteThread,
849
+ onToolbarStar: toolbarActions.toggleStar,
899
850
  onToolbarDiscardDraft: handleToolbarDiscardDraft,
900
- onToolbarMove: handleToolbarMove,
851
+ onToolbarMove: toolbarActions.moveThread,
901
852
  composeState,
902
853
  closeCompose,
903
854
  hasRemitDraftOpen,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * useThreadActions — the reading pane's verbs for one open thread.
3
+ *
4
+ * Delete, move, star and the compose requests (reply / reply-all / forward),
5
+ * over the same mutation hooks the mailbox list uses. The mailbox view keys
6
+ * them by its route; the brief and Flagged are cross-account, so they key by
7
+ * the open thread's own `mailboxId` / `accountConfigId` (#149).
8
+ */
9
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
10
+ import { useCallback, useState } from "react";
11
+ import type { ComposeMode } from "@/components/compose/ComposeProvider";
12
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
13
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
14
+ import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
15
+ import { useToggleStar } from "@/hooks/useToggleStar";
16
+
17
+ interface UseThreadActionsOptions {
18
+ thread: RemitImapThreadMessageResponse | undefined;
19
+ /** Mailbox whose listings the mutations patch. Defaults to the thread's own. */
20
+ mailboxId?: string;
21
+ /** Account the move picker offers folders from. Defaults to the thread's own. */
22
+ accountId?: string;
23
+ onAfterOptimisticRemove?: (messageIds: string[]) => void;
24
+ }
25
+
26
+ export interface ThreadActions {
27
+ mailboxId: string | undefined;
28
+ accountId: string | undefined;
29
+ isStarred: boolean | undefined;
30
+ deleteThread: () => void;
31
+ moveThread: (destinationMailboxId: string) => void;
32
+ toggleStar: () => void;
33
+ composeRequest: ComposeMode | null;
34
+ requestCompose: (mode: ComposeMode) => void;
35
+ clearComposeRequest: () => void;
36
+ }
37
+
38
+ export const useThreadActions = ({
39
+ thread,
40
+ mailboxId,
41
+ accountId,
42
+ onAfterOptimisticRemove,
43
+ }: UseThreadActionsOptions): ThreadActions => {
44
+ const resolvedMailboxId = mailboxId ?? thread?.mailboxId;
45
+ const resolvedAccountId = accountId ?? thread?.accountConfigId;
46
+ const threadMessageIds = useThreadMessageIds();
47
+
48
+ const { deleteMessages } = useDeleteMessages({
49
+ mailboxId: resolvedMailboxId ?? "",
50
+ threadId: thread?.threadId,
51
+ accountId: resolvedAccountId,
52
+ onAfterOptimisticRemove,
53
+ });
54
+
55
+ const { moveMessages } = useMoveMessages({
56
+ mailboxId: resolvedMailboxId ?? "",
57
+ threadId: thread?.threadId,
58
+ accountId: resolvedAccountId,
59
+ onAfterOptimisticRemove,
60
+ });
61
+
62
+ const { toggleStar: toggleStarFor } = useToggleStar({
63
+ threadId: thread?.threadId ?? "",
64
+ mailboxId: resolvedMailboxId ?? "",
65
+ });
66
+
67
+ const deleteThread = useCallback(() => {
68
+ if (!thread) return;
69
+ deleteMessages(threadMessageIds(thread));
70
+ }, [thread, threadMessageIds, deleteMessages]);
71
+
72
+ const moveThread = useCallback(
73
+ (destinationMailboxId: string) => {
74
+ if (!thread) return;
75
+ moveMessages(threadMessageIds(thread), destinationMailboxId);
76
+ },
77
+ [thread, threadMessageIds, moveMessages],
78
+ );
79
+
80
+ const toggleStar = useCallback(() => {
81
+ if (!thread) return;
82
+ toggleStarFor(thread.messageId, thread.hasStars);
83
+ }, [thread, toggleStarFor]);
84
+
85
+ const [composeRequest, setComposeRequest] = useState<ComposeMode | null>(
86
+ null,
87
+ );
88
+ const clearComposeRequest = useCallback(() => setComposeRequest(null), []);
89
+
90
+ return {
91
+ mailboxId: resolvedMailboxId,
92
+ accountId: resolvedAccountId,
93
+ isStarred: thread?.hasStars,
94
+ deleteThread,
95
+ moveThread,
96
+ toggleStar,
97
+ composeRequest,
98
+ requestCompose: setComposeRequest,
99
+ clearComposeRequest,
100
+ };
101
+ };
@@ -0,0 +1,33 @@
1
+ import { threadDetailOperationsListThreadMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback } from "react";
5
+
6
+ /**
7
+ * Every message id in a thread, read from the cached thread-messages listing.
8
+ *
9
+ * A toolbar verb acts on the whole conversation, not the one row that
10
+ * represents it in a list. When the conversation has not been opened yet its
11
+ * messages are not cached, and the representative message id is the best the
12
+ * caller can do.
13
+ */
14
+ export const useThreadMessageIds = (): ((
15
+ thread: RemitImapThreadMessageResponse,
16
+ ) => string[]) => {
17
+ const queryClient = useQueryClient();
18
+ return useCallback(
19
+ (thread: RemitImapThreadMessageResponse) => {
20
+ const threadKey = threadDetailOperationsListThreadMessagesQueryKey({
21
+ path: { threadId: thread.threadId },
22
+ });
23
+ const cached = queryClient.getQueriesData<{
24
+ items: { messageId: string }[];
25
+ }>({ queryKey: threadKey });
26
+ const ids = cached.flatMap(
27
+ ([, data]) => data?.items.map((m) => m.messageId) ?? [],
28
+ );
29
+ return ids.length > 0 ? ids : [thread.messageId];
30
+ },
31
+ [queryClient],
32
+ );
33
+ };