@remit/web-client 0.0.149 → 0.0.151

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +27 -5
  3. package/src/components/layout/ComposeFab.tsx +3 -2
  4. package/src/components/mail/BriefPane.tsx +8 -8
  5. package/src/components/mail/DailyBrief.tsx +3 -7
  6. package/src/components/mail/DraftsView.tsx +7 -3
  7. package/src/components/mail/IntelligencePane.tsx +4 -3
  8. package/src/components/mail/MailListHeader.tsx +1 -5
  9. package/src/components/mail/MailSidebarAdapter.tsx +4 -3
  10. package/src/components/mail/MailboxPane.tsx +130 -150
  11. package/src/components/mail/MessageActionMenu.tsx +12 -8
  12. package/src/components/mail/MessageList.selection.test.ts +6 -7
  13. package/src/components/mail/MessageList.tsx +28 -32
  14. package/src/components/mail/MessageListItem.tsx +2 -1
  15. package/src/components/mail/MessageRow.tsx +13 -10
  16. package/src/components/mail/OutboxPane.tsx +31 -28
  17. package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +8 -3
  18. package/src/components/mail/SwipeableMessageRow.tsx +8 -12
  19. package/src/hooks/useIntelligenceData.ts +1 -0
  20. package/src/lib/conversation-target.ts +7 -49
  21. package/src/lib/mail-search.ts +13 -11
  22. package/src/routeTree.gen.ts +96 -0
  23. package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +19 -0
  24. package/src/routes/mail/$mailboxId/$threadId/index.tsx +14 -0
  25. package/src/routes/mail/$mailboxId/$threadId.tsx +12 -0
  26. package/src/routes/mail/$mailboxId.tsx +7 -3
  27. package/src/routes/mail/brief.tsx +2 -2
  28. package/src/routes/mail/outbox/draft/$outboxMessageId.tsx +19 -0
  29. package/src/routes/mail/outbox.tsx +6 -1
  30. package/src/routing/index.ts +6 -5
  31. package/src/routing/open-thread.ts +58 -0
  32. package/src/routing/outbox-draft.ts +17 -0
  33. package/src/lib/conversation-target.test.ts +0 -68
  34. package/src/lib/search-pending.test.ts +0 -99
  35. package/src/lib/search-pending.ts +0 -49
  36. package/src/routing/brief-thread.ts +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.149",
3
+ "version": "0.0.151",
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": {
@@ -8,7 +8,7 @@ import type {
8
8
  RemitImapDescribeMessageResponse,
9
9
  } from "@remit/api-http-client/types.gen.ts";
10
10
  import { useQuery, useQueryClient } from "@tanstack/react-query";
11
- import { useLocation, useNavigate } from "@tanstack/react-router";
11
+ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
12
12
  import {
13
13
  createContext,
14
14
  useCallback,
@@ -21,6 +21,7 @@ import {
21
21
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
22
22
  import { useComposeTarget } from "@/hooks/useComposeTargetMailbox";
23
23
  import { hostsComposeSurface } from "@/lib/compose-routes";
24
+ import { locationOpensDetail } from "@/lib/mail-route";
24
25
  export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
25
26
 
26
27
  export interface ComposeState {
@@ -66,6 +67,7 @@ export const ComposeProvider = ({
66
67
  const queryClient = useQueryClient();
67
68
  const navigate = useNavigate();
68
69
  const location = useLocation();
70
+ const routeParams = useParams({ strict: false, shouldThrow: false });
69
71
  const { pushError } = useErrorBanners();
70
72
  // Compose is a mail action, so its target only has to be known under `/mail`.
71
73
  // Resolving it from the root otherwise costs `/settings` and `/onboarding` a
@@ -114,9 +116,14 @@ export const ComposeProvider = ({
114
116
  const openCompose = useCallback(
115
117
  (params: Omit<ComposeState, "isOpen">) => {
116
118
  const search = location.search as Record<string, unknown>;
117
- const showsThread = Boolean(
118
- search.selectedMessageId ?? search.selectedThreadId,
119
- );
119
+ // A folder names its open conversation in the path, so leaving it is a
120
+ // navigation up to the list; the lists still to move name it in the query.
121
+ const threadMailboxId = locationOpensDetail(location.pathname)
122
+ ? routeParams?.mailboxId
123
+ : undefined;
124
+ const showsThread =
125
+ Boolean(threadMailboxId) ||
126
+ Boolean(search.selectedMessageId ?? search.selectedThreadId);
120
127
  if (!hostsComposeSurface(location.pathname)) {
121
128
  if (target.status === "loading") {
122
129
  pushError({
@@ -145,6 +152,14 @@ export const ComposeProvider = ({
145
152
  setState({ ...params, isOpen: true });
146
153
  if (!showsThread) return;
147
154
  // A push, so Back reopens the message.
155
+ if (threadMailboxId) {
156
+ navigate({
157
+ to: "/mail/$mailboxId",
158
+ params: { mailboxId: threadMailboxId },
159
+ search: (prev) => prev,
160
+ });
161
+ return;
162
+ }
148
163
  navigate({
149
164
  to: ".",
150
165
  search: (prev: Record<string, unknown>) => ({
@@ -154,7 +169,14 @@ export const ComposeProvider = ({
154
169
  }),
155
170
  });
156
171
  },
157
- [navigate, location.pathname, location.search, target, pushError],
172
+ [
173
+ navigate,
174
+ location.pathname,
175
+ location.search,
176
+ routeParams?.mailboxId,
177
+ target,
178
+ pushError,
179
+ ],
158
180
  );
159
181
 
160
182
  const closeCompose = useCallback(() => {
@@ -14,8 +14,9 @@ import { locationOpensDetail } from "@/lib/mail-route";
14
14
  * `lg:hidden` class covers the pre-hydration frame.
15
15
  * - The compose surface is already open.
16
16
  * - The user is reading a thread — the single pane is the conversation, and
17
- * its reply bar is under this corner. The brief says so in its path; the
18
- * lists still to move say so in `?selectedMessageId=…`.
17
+ * its reply bar is under this corner. The brief and a folder say so in
18
+ * their path; the flagged list, still to move, says so in
19
+ * `?selectedMessageId=…`.
19
20
  * - The user is off `/mail`, which is every route with no mail in it.
20
21
  */
21
22
  export const ComposeFab = () => {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage in the list layout route:
5
5
  *
6
- * <BriefPane thread={useBriefThreadPath()}>
6
+ * <BriefPane thread={useOpenThreadPath()}>
7
7
  * <MailShell
8
8
  * list={<BriefPane.List />}
9
9
  * reading={<Outlet />}
@@ -41,7 +41,7 @@ import {
41
41
  } from "@/hooks/useTriageLayer";
42
42
  import type { ConversationTarget } from "@/lib/conversation-target";
43
43
  import { useMailContext } from "@/lib/mail-context";
44
- import type { BriefThreadPath, BriefThreadTarget } from "@/routing";
44
+ import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
45
45
 
46
46
  /* ------------------------------------------------------------------ */
47
47
  /* Context */
@@ -54,13 +54,13 @@ interface BriefPaneContextValue {
54
54
  /** The conversation the pane shows, or none when the address names no thread. */
55
55
  conversation: ConversationTarget | undefined;
56
56
  onOpenThread: (
57
- target: BriefThreadTarget,
57
+ target: OpenThreadTarget,
58
58
  options?: OpenMessageOptions,
59
59
  ) => void;
60
60
  onCloseThread: () => void;
61
61
  /** The rows either side of the open one — the phone's swipe gestures. */
62
- nextThread: BriefThreadTarget | undefined;
63
- previousThread: BriefThreadTarget | undefined;
62
+ nextThread: OpenThreadTarget | undefined;
63
+ previousThread: OpenThreadTarget | undefined;
64
64
  /**
65
65
  * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
66
66
  * account — the brief spans accounts, so there is no route mailbox to key by.
@@ -92,7 +92,7 @@ function useBriefPane(): BriefPaneContextValue {
92
92
 
93
93
  interface BriefPaneProps {
94
94
  /** The open conversation, as the address states it. */
95
- thread: BriefThreadPath | undefined;
95
+ thread: OpenThreadPath | undefined;
96
96
  children: ReactNode;
97
97
  }
98
98
 
@@ -137,7 +137,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
137
137
  }, [threadId, selectedThread, selectedMessageId]);
138
138
 
139
139
  const handleOpenThread = useCallback(
140
- (target: BriefThreadTarget, options?: OpenMessageOptions) => {
140
+ (target: OpenThreadTarget, options?: OpenMessageOptions) => {
141
141
  navigate({
142
142
  to: "/mail/brief/$threadId/$messageId",
143
143
  params: target,
@@ -225,7 +225,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
225
225
  // name its thread. The brief's own listing is where that is looked up, so a
226
226
  // row it does not hold offers no gesture rather than a tap that goes nowhere.
227
227
  const adjacentThread = useCallback(
228
- (messageId: string | undefined): BriefThreadTarget | undefined => {
228
+ (messageId: string | undefined): OpenThreadTarget | undefined => {
229
229
  if (!messageId) return undefined;
230
230
  const row = briefThreads.find((t) => t.messageId === messageId);
231
231
  return row ? { threadId: row.threadId, messageId } : undefined;
@@ -105,7 +105,7 @@ import {
105
105
  type SelectionWizardControl,
106
106
  useSelectionWizard,
107
107
  } from "@/lib/wizard-history";
108
- import type { BriefThreadTarget } from "@/routing";
108
+ import type { OpenThreadTarget } from "@/routing";
109
109
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
110
110
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
111
111
  import type { MessageListCommands } from "./MessageList";
@@ -385,7 +385,7 @@ interface DailyBriefProps {
385
385
  * from the unified inbox.
386
386
  */
387
387
  onOpenThread?: (
388
- target: BriefThreadTarget,
388
+ target: OpenThreadTarget,
389
389
  options?: OpenMessageOptions,
390
390
  ) => void;
391
391
  /** Where the list publishes the commands the keyboard layer drives. */
@@ -825,11 +825,7 @@ export function DailyBrief({
825
825
  navigate({
826
826
  to: "/mail/$mailboxId",
827
827
  params: { mailboxId: briefSpamOffer.mailboxId },
828
- search: {
829
- q: searchQuery || undefined,
830
- selectedMessageId: undefined,
831
- selectedThreadId: undefined,
832
- },
828
+ search: { q: searchQuery || undefined },
833
829
  })
834
830
  }
835
831
  />
@@ -189,10 +189,14 @@ export function DraftsView({
189
189
  };
190
190
 
191
191
  const handleImapDraftOpen = (messageId: string) => {
192
+ const threadId = imapThreads.find(
193
+ (thread) => thread.messageId === messageId,
194
+ )?.threadId;
195
+ if (!threadId) return;
192
196
  navigate({
193
- to: "/mail/$mailboxId",
194
- params: { mailboxId },
195
- search: { selectedMessageId: messageId },
197
+ to: "/mail/$mailboxId/$threadId/$messageId",
198
+ params: { mailboxId, threadId, messageId },
199
+ search: (prev) => prev,
196
200
  });
197
201
  };
198
202
 
@@ -310,15 +310,16 @@ function WiredPanel({
310
310
  // preserving deep-linking, middle-click, and existing search params.
311
311
  const similarLinkComponent: SimilarMessageLinkComponent = ({
312
312
  mailboxId: rowMailboxId,
313
+ threadId,
313
314
  messageId,
314
315
  className,
315
316
  ariaLabel,
316
317
  children,
317
318
  }) => (
318
319
  <NavLink
319
- to="/mail/$mailboxId"
320
- params={{ mailboxId: rowMailboxId }}
321
- search={(prev) => ({ ...prev, selectedMessageId: messageId })}
320
+ to="/mail/$mailboxId/$threadId/$messageId"
321
+ params={{ mailboxId: rowMailboxId, threadId, messageId }}
322
+ search={(prev) => prev}
322
323
  variant="row"
323
324
  className={className}
324
325
  aria-label={ariaLabel}
@@ -323,11 +323,7 @@ export function MailListHeader({
323
323
  navigate({
324
324
  to: "/mail/$mailboxId",
325
325
  params: { mailboxId: spamOffer.mailboxId },
326
- search: {
327
- q: searchQuery || undefined,
328
- selectedMessageId: undefined,
329
- selectedThreadId: undefined,
330
- },
326
+ search: { q: searchQuery || undefined },
331
327
  }),
332
328
  }
333
329
  : routeScope;
@@ -244,7 +244,7 @@ export function MailSidebarAdapter({
244
244
  return (
245
245
  <NavLink
246
246
  to="/mail/outbox"
247
- search={{ q: undefined, selectedOutboxMessageId: undefined }}
247
+ search={{ q: undefined }}
248
248
  onClick={() => onClick?.()}
249
249
  className={className}
250
250
  aria-label={ariaLabel}
@@ -285,8 +285,9 @@ export function MailSidebarAdapter({
285
285
  <NavLink
286
286
  to="/mail/$mailboxId"
287
287
  params={{ mailboxId: navId }}
288
- // Drop any stale search query / selected message when switching mailbox.
289
- search={{ q: undefined, selectedMessageId: undefined }}
288
+ // Drop any stale search query when switching mailbox; the list route
289
+ // is above the open conversation, so it leaves nothing behind.
290
+ search={{ q: undefined }}
290
291
  onClick={() => {
291
292
  invalidateMailboxThreads(navId);
292
293
  onClick?.();