@remit/web-client 0.0.161 → 0.0.163

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.161",
3
+ "version": "0.0.163",
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": {
@@ -14,6 +14,7 @@ import {
14
14
  ComposeFormShell,
15
15
  ComposeHeader,
16
16
  type ComposeSendState,
17
+ type ComposeShellLayout,
17
18
  ComposeSubjectField,
18
19
  composeHeaderSummary,
19
20
  defaultComposeLanguages,
@@ -67,6 +68,12 @@ interface ComposeFormProps {
67
68
  sourceMessage?: RemitImapDescribeMessageResponse;
68
69
  onClose: () => void;
69
70
  onAccountChange?: (account: RemitImapAccountResponse) => void;
71
+ /**
72
+ * How the surface takes its height. A window in its own pane fills that
73
+ * pane; one opened as a block of the conversation grows with what is written
74
+ * in it and leaves the pane the only scroller.
75
+ */
76
+ layout?: ComposeShellLayout;
70
77
  }
71
78
 
72
79
  const escapeHtml = (text: string): string =>
@@ -301,6 +308,7 @@ export const ComposeForm = ({
301
308
  sourceMessage,
302
309
  onClose,
303
310
  onAccountChange,
311
+ layout = "fill",
304
312
  }: ComposeFormProps) => {
305
313
  const { state, setOutboxMessageId, startSendPolling } = useCompose();
306
314
  const { pushError } = useErrorBanners();
@@ -751,6 +759,7 @@ export const ComposeForm = ({
751
759
 
752
760
  return (
753
761
  <ComposeFormShell
762
+ layout={layout}
754
763
  banner={
755
764
  selectedAccount && selectedAccountMissingSmtp ? (
756
765
  <ComposeSmtpMissingBanner
@@ -12,22 +12,22 @@ interface InlineComposeProps {
12
12
  onClose: () => void;
13
13
  }
14
14
 
15
+ /**
16
+ * The reply, as the top block of the conversation it answers. It takes no
17
+ * height of its own: it is as tall as what has been written in it and pushes
18
+ * the thread down as that grows, so the pane keeps the one scrollbar it had
19
+ * before the reply opened. A height here would give the pane a second one, with
20
+ * the caret in the inner track.
21
+ */
15
22
  export const InlineCompose = ({
16
23
  mode,
17
24
  account,
18
25
  sourceMessage,
19
26
  onClose,
20
27
  }: InlineComposeProps) => (
21
- // Capped against the viewport as well as in pixels: on a short one — a phone
22
- // in landscape, or one with the keyboard up — a flat 400px is taller than
23
- // the pane, and the surface's own header scrolls off before its verbs do.
24
- //
25
- // A desktop pane has room a phone does not, so the surface takes a height
26
- // there rather than a ceiling. Left to size itself, the recipient rows and
27
- // the verbs come first and the writing area gets its 120px floor — a couple
28
- // of lines to answer a message in.
29
- <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col lg:h-[min(560px,55dvh)] lg:max-h-none">
28
+ <div className="border-b border-line bg-canvas">
30
29
  <ComposeForm
30
+ layout="flow"
31
31
  mode={mode}
32
32
  account={account}
33
33
  sourceMessage={sourceMessage}
@@ -41,7 +41,11 @@ 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 { OpenThreadPath, OpenThreadTarget } from "@/routing";
44
+ import {
45
+ type OpenThreadPath,
46
+ type OpenThreadTarget,
47
+ retainOpenPanels,
48
+ } from "@/routing";
45
49
 
46
50
  /* ------------------------------------------------------------------ */
47
51
  /* Context */
@@ -148,13 +152,18 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
148
152
  // `searchInput`: a row can be tapped before the debounce settles, when
149
153
  // the committed query is still empty.
150
154
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
155
+ hash: retainOpenPanels,
151
156
  });
152
157
  },
153
158
  [navigate, searchInput],
154
159
  );
155
160
 
156
161
  const handleCloseThread = useCallback(() => {
157
- navigate({ to: "/mail/brief", search: (prev) => prev });
162
+ navigate({
163
+ to: "/mail/brief",
164
+ search: (prev) => prev,
165
+ hash: retainOpenPanels,
166
+ });
158
167
  }, [navigate]);
159
168
 
160
169
  const handleDeselectIfRemoved = useCallback(
@@ -122,8 +122,9 @@ export const ConversationView = ({
122
122
  error,
123
123
  refetch,
124
124
  } = useQuery({
125
- // The API reads a conversation oldest first (#81) and the pane renders it
126
- // in that order, so no `order` is asked for and none is re-sorted here.
125
+ // The API reads a conversation oldest first (#81) and no `order` is asked
126
+ // for: which turn came first is the thread's own fact, and which end of it
127
+ // the pane puts at the top is the pane's.
127
128
  ...threadDetailOperationsListThreadMessagesOptions({
128
129
  path: { threadId },
129
130
  }),
@@ -134,6 +135,11 @@ export const ConversationView = ({
134
135
  [messagesResponse?.items],
135
136
  );
136
137
  const latestMessage = messages[messages.length - 1];
138
+
139
+ // Newest first, under the reply that answers it. The turn a reader came for
140
+ // is the last one, and reading it should not cost a scroll past everything
141
+ // that led to it.
142
+ const orderedMessages = useMemo(() => [...messages].reverse(), [messages]);
137
143
  const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
138
144
 
139
145
  // Track which messages are expanded
@@ -158,7 +164,7 @@ export const ConversationView = ({
158
164
  if (selected) initialExpanded.add(selected.threadMessageId);
159
165
  }
160
166
  setExpandedIds(initialExpanded);
161
- setFocusedIndex(messages.length - 1);
167
+ setFocusedIndex(0);
162
168
  }
163
169
  }, [threadId, messages, currentThreadId, selectedMessageId]);
164
170
 
@@ -174,38 +180,39 @@ export const ConversationView = ({
174
180
  });
175
181
  }, []);
176
182
 
177
- // Scroll focused message into view
183
+ // Scroll focused message into view. j and k walk the pane as it reads, so
184
+ // the index they move is over the displayed order, not the fetched one.
178
185
  const scrollToMessage = useCallback(
179
186
  (index: number) => {
180
- const message = messages[index];
187
+ const message = orderedMessages[index];
181
188
  if (!message) return;
182
189
  const element = messageRefs.current.get(message.threadMessageId);
183
190
  element?.scrollIntoView({ behavior: "smooth", block: "nearest" });
184
191
  },
185
- [messages],
192
+ [orderedMessages],
186
193
  );
187
194
 
188
195
  // Keyboard navigation handlers
189
196
  const focusNext = useCallback(() => {
190
- if (messages.length === 0) return;
191
- const nextIndex = Math.min(focusedIndex + 1, messages.length - 1);
197
+ if (orderedMessages.length === 0) return;
198
+ const nextIndex = Math.min(focusedIndex + 1, orderedMessages.length - 1);
192
199
  setFocusedIndex(nextIndex);
193
200
  scrollToMessage(nextIndex);
194
- }, [messages.length, focusedIndex, scrollToMessage]);
201
+ }, [orderedMessages.length, focusedIndex, scrollToMessage]);
195
202
 
196
203
  const focusPrevious = useCallback(() => {
197
- if (messages.length === 0) return;
204
+ if (orderedMessages.length === 0) return;
198
205
  const prevIndex = Math.max(focusedIndex - 1, 0);
199
206
  setFocusedIndex(prevIndex);
200
207
  scrollToMessage(prevIndex);
201
- }, [messages.length, focusedIndex, scrollToMessage]);
208
+ }, [orderedMessages.length, focusedIndex, scrollToMessage]);
202
209
 
203
210
  const toggleFocusedMessage = useCallback(() => {
204
- const message = messages[focusedIndex];
211
+ const message = orderedMessages[focusedIndex];
205
212
  if (message) {
206
213
  toggleExpanded(message.threadMessageId);
207
214
  }
208
- }, [messages, focusedIndex, toggleExpanded]);
215
+ }, [orderedMessages, focusedIndex, toggleExpanded]);
209
216
 
210
217
  const { data: config } = useQuery({
211
218
  ...configOperationsGetConfigOptions(),
@@ -282,25 +289,18 @@ export const ConversationView = ({
282
289
  onComposeClose?.();
283
290
  }, [onComposeClose]);
284
291
 
285
- // Compose opens below the message, which on a long one is several screens
286
- // down: without this, replying to it looks like nothing happening.
287
- //
288
- // Aligned on its bottom edge, where the send and discard verbs are, and held
289
- // there while it changes height. It mounts before the message it quotes has
290
- // been fetched and grows when that lands, so a single scroll at open time
291
- // puts whatever it grows past back below the fold.
292
+ // Compose opens at the head of the pane, which from halfway down a long
293
+ // thread is several screens up: without this, replying looks like nothing
294
+ // happening. Aligned on its top edge, where the recipients are, and left
295
+ // alone afterwards it grows downward as it is written into, so following
296
+ // its height would pull the rows being typed into off the top.
292
297
  const composeRef = useRef<HTMLDivElement>(null);
293
298
  useEffect(() => {
294
299
  if (composeMode === null || composeOpenings === 0) return;
295
- const surface = composeRef.current;
296
- if (!surface) return;
297
- const reveal = () =>
298
- surface.scrollIntoView({ behavior: "smooth", block: "end" });
299
- reveal();
300
- if (typeof ResizeObserver === "undefined") return;
301
- const observer = new ResizeObserver(reveal);
302
- observer.observe(surface);
303
- return () => observer.disconnect();
300
+ composeRef.current?.scrollIntoView({
301
+ behavior: "smooth",
302
+ block: "start",
303
+ });
304
304
  }, [composeOpenings, composeMode]);
305
305
 
306
306
  // Register keyboard shortcuts
@@ -357,8 +357,8 @@ export const ConversationView = ({
357
357
  // wired whatever the account's SMTP state: compose is where an account that
358
358
  // cannot send says so.
359
359
  const renderMessages = (mobile: boolean) => (
360
- <div>
361
- {messages.map((message, index) => (
360
+ <div data-testid="conversation-messages">
361
+ {orderedMessages.map((message, index) => (
362
362
  <div
363
363
  key={message.threadMessageId}
364
364
  ref={(el) => {
@@ -385,6 +385,21 @@ export const ConversationView = ({
385
385
  </div>
386
386
  );
387
387
 
388
+ // The reply leads the pane, above the turn it answers: what is being written
389
+ // is what the reader came back for, and it is not something to go looking
390
+ // for under a thread. It scrolls with the conversation rather than floating
391
+ // over it, so the pane has one scrollbar whether or not a reply is open.
392
+ const compose = composeMode !== null && (
393
+ <div ref={composeRef}>
394
+ <InlineCompose
395
+ mode={composeMode}
396
+ account={activeAccount}
397
+ sourceMessage={latestMessageData}
398
+ onClose={handleCloseCompose}
399
+ />
400
+ </div>
401
+ );
402
+
388
403
  // Subject block: matches the AppShell ReadingPane reference exactly —
389
404
  // px-5 pt-5 pb-3, text-lg leading-snug, 2xs count. Subject scrolls
390
405
  // with the thread body; no subject in the toolbar chrome.
@@ -419,17 +434,8 @@ export const ConversationView = ({
419
434
  onOpenIntelligence={onOpenIntelligence}
420
435
  />
421
436
  )}
437
+ {compose}
422
438
  {renderMessages(true)}
423
- {composeMode !== null && (
424
- <div ref={composeRef}>
425
- <InlineCompose
426
- mode={composeMode}
427
- account={activeAccount}
428
- sourceMessage={latestMessageData}
429
- onClose={handleCloseCompose}
430
- />
431
- </div>
432
- )}
433
439
  </MobileReadingPane>
434
440
  );
435
441
  }
@@ -443,22 +449,12 @@ export const ConversationView = ({
443
449
  onOpenIntelligence={onOpenIntelligence}
444
450
  />
445
451
  )}
446
- {/* The reply is part of the conversation, so it scrolls with it. Held
447
- below the pane instead, it took whatever height the message left
448
- over nothing on a long one — and no amount of scrolling could
449
- reach it. */}
452
+ {/* The one scroller in the pane. The reply and the thread are both in
453
+ it, so neither has a scrollbar of its own and neither is squeezed
454
+ by the height the other takes. */}
450
455
  <div className="min-h-0 flex-1 overflow-auto">
456
+ {compose}
451
457
  {renderMessages(false)}
452
- {composeMode !== null && (
453
- <div ref={composeRef}>
454
- <InlineCompose
455
- mode={composeMode}
456
- account={activeAccount}
457
- sourceMessage={latestMessageData}
458
- onClose={handleCloseCompose}
459
- />
460
- </div>
461
- )}
462
458
  </div>
463
459
  </article>
464
460
  );
@@ -41,6 +41,7 @@ import { FileText, Inbox, Trash2 } from "lucide-react";
41
41
  import { useMemo } from "react";
42
42
  import { useCompose } from "@/components/compose/ComposeProvider";
43
43
  import { groupDraftSections } from "@/lib/drafts";
44
+ import { retainOpenPanels } from "@/routing";
44
45
  import { NavMenuButton } from "./NavMenuButton";
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -197,6 +198,7 @@ export function DraftsView({
197
198
  to: "/mail/$mailboxId/$threadId/$messageId",
198
199
  params: { mailboxId, threadId, messageId },
199
200
  search: (prev) => prev,
201
+ hash: retainOpenPanels,
200
202
  });
201
203
  };
202
204
 
@@ -52,7 +52,11 @@ import {
52
52
  } from "@/hooks/useTriageLayer";
53
53
  import type { ConversationTarget } from "@/lib/conversation-target";
54
54
  import { useMailContext } from "@/lib/mail-context";
55
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
55
+ import {
56
+ type OpenThreadPath,
57
+ type OpenThreadTarget,
58
+ retainOpenPanels,
59
+ } from "@/routing";
56
60
 
57
61
  /* ------------------------------------------------------------------ */
58
62
  /* Context */
@@ -154,13 +158,18 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
154
158
  // `searchInput`: a row can be tapped before the debounce settles, when
155
159
  // the committed query is still empty.
156
160
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
161
+ hash: retainOpenPanels,
157
162
  });
158
163
  },
159
164
  [navigate, searchInput],
160
165
  );
161
166
 
162
167
  const handleCloseThread = useCallback(() => {
163
- navigate({ to: "/mail/flagged", search: (prev) => prev });
168
+ navigate({
169
+ to: "/mail/flagged",
170
+ search: (prev) => prev,
171
+ hash: retainOpenPanels,
172
+ });
164
173
  }, [navigate]);
165
174
 
166
175
  const handleDeselectIfRemoved = useCallback(
@@ -108,7 +108,6 @@ import {
108
108
  inboxFilterParams,
109
109
  sameInboxFilter,
110
110
  } from "@/lib/inbox-filters";
111
- import { readIntelligencePref } from "@/lib/intelligence-pref";
112
111
  import { junkDestination } from "@/lib/junk-destination";
113
112
  import { useMailContext } from "@/lib/mail-context";
114
113
  import { useMailFreshness } from "@/lib/mail-freshness";
@@ -125,7 +124,11 @@ import {
125
124
  applyResidualTokens,
126
125
  threadSearchTokens,
127
126
  } from "@/lib/thread-search-tokens";
128
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
127
+ import {
128
+ type OpenThreadPath,
129
+ type OpenThreadTarget,
130
+ retainOpenPanels,
131
+ } from "@/routing";
129
132
  import { MailViewChrome } from "./MailViewChrome";
130
133
 
131
134
  /* ------------------------------------------------------------------ */
@@ -263,16 +266,9 @@ function MailboxPaneProvider({
263
266
  const navigate = useNavigate();
264
267
  const threadId = thread?.threadId;
265
268
  const pointedAtMessageId = thread?.messageId;
266
- const tier = useLayoutTier();
267
- const isDesktop = tier === "desktop";
268
269
  const telemetry = useTelemetry();
269
- const {
270
- accounts,
271
- searchQuery,
272
- intelligenceOpen,
273
- onToggleIntelligence,
274
- onSetIntelligenceOpen,
275
- } = useMailContext();
270
+ const { accounts, searchQuery, intelligenceOpen, onToggleIntelligence } =
271
+ useMailContext();
276
272
  const tokenContext = useSearchTokenContext();
277
273
 
278
274
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -471,6 +467,7 @@ function MailboxPaneProvider({
471
467
  to: "/mail/$mailboxId",
472
468
  params: { mailboxId },
473
469
  search: (prev) => prev,
470
+ hash: retainOpenPanels,
474
471
  });
475
472
  }, [mailboxId, navigate]);
476
473
 
@@ -480,6 +477,7 @@ function MailboxPaneProvider({
480
477
  to: "/mail/$mailboxId/$threadId/$messageId",
481
478
  params: { mailboxId, ...target },
482
479
  search: (prev) => prev,
480
+ hash: retainOpenPanels,
483
481
  });
484
482
  },
485
483
  [mailboxId, navigate],
@@ -535,23 +533,6 @@ function MailboxPaneProvider({
535
533
  onToggleIntelligence,
536
534
  ]);
537
535
 
538
- // Desktop default-open (#782)
539
- const appliedDefaultRef = useRef(false);
540
- useEffect(() => {
541
- if (appliedDefaultRef.current) return;
542
- if (!isDesktop) return;
543
- if (!selectedThread?.messageId) return;
544
- appliedDefaultRef.current = true;
545
- if (readIntelligencePref() && !intelligenceOpen) {
546
- onSetIntelligenceOpen(true);
547
- }
548
- }, [
549
- isDesktop,
550
- selectedThread?.messageId,
551
- intelligenceOpen,
552
- onSetIntelligenceOpen,
553
- ]);
554
-
555
536
  // The mailbox's own unseen total. A count over the loaded pages undercounts
556
537
  // every mailbox larger than one page and creeps upward as the user scrolls,
557
538
  // so there is no fallback: until the mailbox resolves there is no number.
@@ -1048,6 +1029,7 @@ function MailboxList() {
1048
1029
  // `searchInput`: a row can be tapped before the debounce settles, when
1049
1030
  // the committed query is still empty.
1050
1031
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
1032
+ hash: retainOpenPanels,
1051
1033
  });
1052
1034
  },
1053
1035
  [mailboxId, navigate, searchInput, threads],
@@ -35,7 +35,7 @@ import {
35
35
  type ThreadListSnapshotEntry,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
- import { useOpenThreadPath } from "@/routing";
38
+ import { retainOpenPanels, useOpenThreadPath } from "@/routing";
39
39
  import { MoveToTrigger } from "./MoveToTrigger";
40
40
 
41
41
  interface ThreadMessagesData {
@@ -193,6 +193,7 @@ export const MessageActionMenu = ({
193
193
  to: "/mail/$mailboxId",
194
194
  params: { mailboxId },
195
195
  search: (prev) => prev,
196
+ hash: retainOpenPanels,
196
197
  });
197
198
  },
198
199
  [selectedMessageId, mailboxId, navigate],
@@ -68,8 +68,8 @@ interface MessageBodyProps {
68
68
  category?: EmailRenderCategory;
69
69
  /**
70
70
  * Extra classes for the `.message-body` wrapper. `MessageCard` leaves this
71
- * unset: the kit's `ExpandedMessage` runs the body edge to edge on a phone
72
- * and supplies the desktop inset itself.
71
+ * unset: the kit's `ExpandedMessage` owns the gutter, and runs the sandboxed
72
+ * email frame out of it so mail renders on its own ground.
73
73
  */
74
74
  className?: string;
75
75
  }
@@ -324,14 +324,11 @@ const ExpandedCard = ({
324
324
  isTrusted={isTrusted}
325
325
  category={toDisplayCategory(threadMessage.category)}
326
326
  />
327
- {/* The body slot runs edge to edge on a phone; the attachment
328
- list is app chrome, not part of the email, so it takes the
329
- gutter back. */}
330
327
  <MessageAttachments
331
328
  messageId={threadMessage.messageId}
332
329
  bodyParts={messageData?.bodyParts}
333
330
  hasAttachment={threadMessage.hasAttachment}
334
- className="mt-4 px-2 lg:px-0"
331
+ className="mt-4"
335
332
  />
336
333
  </div>
337
334
  )
@@ -53,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
53
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
54
  import { cn } from "@/lib/utils";
55
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
56
+ import { retainOpenPanels } from "@/routing";
56
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
57
58
  import {
58
59
  type EscalatedSelection,
@@ -634,6 +635,7 @@ export const MessageList = ({
634
635
  to: "/mail/$mailboxId/$threadId/$messageId",
635
636
  params: { mailboxId, threadId, messageId },
636
637
  search: (prev) => prev,
638
+ hash: retainOpenPanels,
637
639
  replace: options?.replace,
638
640
  });
639
641
  },
@@ -63,6 +63,7 @@ import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
63
63
  import { normalizeSearchQuery } from "@/lib/search-query";
64
64
  import { parseSearchTokens } from "@/lib/search-tokens";
65
65
  import { cn } from "@/lib/utils";
66
+ import { retainOpenPanels } from "@/routing";
66
67
 
67
68
  /* ------------------------------------------------------------------ */
68
69
  /* Helpers (shared between List, Reading, Phone sub-views) */
@@ -218,13 +219,18 @@ function OutboxPaneProvider({
218
219
  to: "/mail/outbox/draft/$outboxMessageId",
219
220
  params: { outboxMessageId },
220
221
  search: (prev) => prev,
222
+ hash: retainOpenPanels,
221
223
  });
222
224
  },
223
225
  [navigate],
224
226
  );
225
227
 
226
228
  const handleCloseMessage = useCallback(() => {
227
- navigate({ to: "/mail/outbox", search: (prev) => prev });
229
+ navigate({
230
+ to: "/mail/outbox",
231
+ search: (prev) => prev,
232
+ hash: retainOpenPanels,
233
+ });
228
234
  }, [navigate]);
229
235
 
230
236
  const ctx: OutboxPaneContextValue = {
@@ -10,6 +10,7 @@ import { useNavigate } from "@tanstack/react-router";
10
10
  import { useCallback, useState } from "react";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
+ import { retainOpenPanels } from "@/routing";
13
14
  import { MessageListItem } from "./MessageListItem";
14
15
  import { useModifierSelect } from "./useModifierSelect";
15
16
 
@@ -105,6 +106,7 @@ export const SwipeableMessageRow = ({
105
106
  messageId: thread.messageId,
106
107
  },
107
108
  search: (prev) => prev,
109
+ hash: retainOpenPanels,
108
110
  });
109
111
  }, [navigate, mailboxId, thread.threadId, thread.messageId]);
110
112