@remit/web-client 0.0.12 → 0.0.14
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 +1 -1
- package/src/auth/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/layout/ComposeFab.tsx +19 -34
- package/src/components/layout/MailTopBar.tsx +64 -0
- package/src/components/mail/BriefPane.tsx +1 -12
- package/src/components/mail/FlaggedPane.tsx +1 -12
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailListHeader.tsx +9 -5
- package/src/components/mail/MailboxPane.tsx +0 -10
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageToolbar.render.test.ts +39 -0
- package/src/components/mail/MessageToolbar.tsx +8 -48
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useComposeTarget.ts +92 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useLayoutTier.test.ts +26 -0
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/compose-routes.test.ts +44 -0
- package/src/lib/compose-routes.ts +25 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/route-search-query.test.ts +48 -0
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
- package/src/routes/mail/outbox.tsx +5 -0
- package/src/routes/mail.tsx +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
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": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
|
+
import { taggedFetch } from "../lib/network-error";
|
|
2
3
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -51,7 +52,9 @@ const decodeExp = (token: string): number => {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
const requestToken = async (): Promise<string | null> => {
|
|
54
|
-
const res = await
|
|
55
|
+
const res = await taggedFetch("/api/auth/token", {
|
|
56
|
+
credentials: "include",
|
|
57
|
+
});
|
|
55
58
|
if (!res.ok) return null;
|
|
56
59
|
const body: unknown = await res.json();
|
|
57
60
|
if (
|
|
@@ -419,6 +419,7 @@ export const ComposeForm = ({
|
|
|
419
419
|
pushError({
|
|
420
420
|
title: "Couldn't save draft",
|
|
421
421
|
detail: formatErrorDetail(saveError) ?? "Saving the draft failed.",
|
|
422
|
+
error: saveError,
|
|
422
423
|
});
|
|
423
424
|
}, [saveError, pushError]);
|
|
424
425
|
|
|
@@ -541,6 +542,7 @@ export const ComposeForm = ({
|
|
|
541
542
|
pushError({
|
|
542
543
|
title: "Couldn't send message",
|
|
543
544
|
detail: formatErrorDetail(error) ?? "Saving the draft failed.",
|
|
545
|
+
error,
|
|
544
546
|
});
|
|
545
547
|
return null;
|
|
546
548
|
});
|
|
@@ -562,6 +564,7 @@ export const ComposeForm = ({
|
|
|
562
564
|
(createdThisAttempt
|
|
563
565
|
? "The draft was saved but the send request failed. Try again from the Outbox."
|
|
564
566
|
: "The send request failed. Try again."),
|
|
567
|
+
error,
|
|
565
568
|
});
|
|
566
569
|
return null;
|
|
567
570
|
});
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { useLocation } from "@tanstack/react-router";
|
|
2
3
|
import { Pencil } from "lucide-react";
|
|
3
|
-
import { startTransition } from "react";
|
|
4
4
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Returns true when `pathname` is `/mail/<id>` for any id other than
|
|
8
|
-
* the reserved `outbox` segment. That route owns a `FullCompose` mount
|
|
9
|
-
* point, so the FAB can open compose in-place. From every other route
|
|
10
|
-
* the FAB has to send the user there first.
|
|
11
|
-
*/
|
|
12
|
-
const isOnMailboxRoute = (pathname: string): boolean =>
|
|
13
|
-
/^\/mail\/(?!outbox)[^/?]+/.test(pathname);
|
|
5
|
+
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
14
6
|
|
|
15
7
|
/**
|
|
16
8
|
* Primary mobile surfaces where the FAB belongs: anywhere under
|
|
@@ -21,30 +13,34 @@ const isOnMailboxRoute = (pathname: string): boolean =>
|
|
|
21
13
|
const isOnPrimaryMobileRoute = (pathname: string): boolean =>
|
|
22
14
|
pathname.startsWith("/mail") || pathname.startsWith("/settings");
|
|
23
15
|
|
|
16
|
+
interface ComposeFabProps {
|
|
17
|
+
accounts: RemitImapAccountResponse[];
|
|
18
|
+
}
|
|
19
|
+
|
|
24
20
|
/**
|
|
25
21
|
* Floating Action Button for composing a new message. Mobile-only.
|
|
26
22
|
*
|
|
27
23
|
* Layout follows Material 3: 56×56 surface, 16px from the right and
|
|
28
24
|
* bottom edges (plus the iOS safe-area inset). Hidden when any of:
|
|
29
|
-
* - Viewport is `≥
|
|
25
|
+
* - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
|
|
26
|
+
* `/mail` shell also stops mounting the FAB above that width; the
|
|
27
|
+
* `lg:hidden` class covers the pre-hydration frame.
|
|
30
28
|
* - The compose surface is already open.
|
|
31
29
|
* - The user is reading a thread (`?selectedMessageId=…`) — the
|
|
32
30
|
* conversation's Reply/Forward action bar covers that workflow.
|
|
33
31
|
* - The user is not on a primary mobile route (`/mail` or
|
|
34
32
|
* `/settings`).
|
|
35
33
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* lives in `__root.tsx`, so the destination route mounts straight into
|
|
42
|
-
* compose.
|
|
34
|
+
* The tap itself is `useGlobalCompose`, shared with the desktop top bar:
|
|
35
|
+
* it opens compose in place on routes that mount `FullCompose` and
|
|
36
|
+
* otherwise carries the user to a real mailbox that does. Compose state
|
|
37
|
+
* survives that transition because `ComposeProvider` lives in
|
|
38
|
+
* `__root.tsx`.
|
|
43
39
|
*/
|
|
44
|
-
export const ComposeFab = () => {
|
|
45
|
-
const { state
|
|
40
|
+
export const ComposeFab = ({ accounts }: ComposeFabProps) => {
|
|
41
|
+
const { state } = useCompose();
|
|
46
42
|
const location = useLocation();
|
|
47
|
-
const
|
|
43
|
+
const compose = useGlobalCompose(accounts);
|
|
48
44
|
|
|
49
45
|
const search = location.search as Record<string, unknown> | undefined;
|
|
50
46
|
const isReadingThread =
|
|
@@ -58,21 +54,10 @@ export const ComposeFab = () => {
|
|
|
58
54
|
)
|
|
59
55
|
return null;
|
|
60
56
|
|
|
61
|
-
const handleClick = () => {
|
|
62
|
-
// Wrap in startTransition so the suspension that ComposeForm
|
|
63
|
-
// triggers on first mount (lazy chunks, queries) doesn't
|
|
64
|
-
// snap the surrounding chrome into a route-level fallback.
|
|
65
|
-
startTransition(() => {
|
|
66
|
-
openCompose({ mode: "new" });
|
|
67
|
-
});
|
|
68
|
-
if (isOnMailboxRoute(location.pathname)) return;
|
|
69
|
-
navigate({ to: "/mail" });
|
|
70
|
-
};
|
|
71
|
-
|
|
72
57
|
return (
|
|
73
58
|
<button
|
|
74
59
|
type="button"
|
|
75
|
-
onClick={
|
|
60
|
+
onClick={compose}
|
|
76
61
|
aria-label="Compose new message"
|
|
77
62
|
className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
|
|
78
63
|
style={{
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MailTopBar — the app's one search surface and its global actions.
|
|
3
|
+
*
|
|
4
|
+
* Mounted above every pane by the `/mail` shell, so the field spans the nav,
|
|
5
|
+
* the list and the reading pane. It is the app's search, not the list's: the
|
|
6
|
+
* list header drops its own field wherever this bar is mounted, so exactly one
|
|
7
|
+
* search input exists on the page and the "/" shortcut has one target.
|
|
8
|
+
*
|
|
9
|
+
* The actions here are the ones that belong to the app rather than to whatever
|
|
10
|
+
* is currently listed or open — compose, bug report, account. Reply, delete,
|
|
11
|
+
* move and the rest stay on the reading pane's own toolbar, under this bar.
|
|
12
|
+
*/
|
|
13
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import { AppTopBar, Button, SearchBar } from "@remit/ui";
|
|
15
|
+
import { SquarePen } from "lucide-react";
|
|
16
|
+
import { AccountMenu } from "@/auth/AccountMenu";
|
|
17
|
+
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
18
|
+
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
19
|
+
import { tooltipForAction } from "@/lib/keymap";
|
|
20
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
21
|
+
|
|
22
|
+
interface MailTopBarProps {
|
|
23
|
+
accounts: RemitImapAccountResponse[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
27
|
+
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
28
|
+
useMailContext();
|
|
29
|
+
const compose = useGlobalCompose(accounts);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<AppTopBar
|
|
33
|
+
leading={
|
|
34
|
+
<span className="px-1 text-sm font-semibold tracking-tight text-fg">
|
|
35
|
+
remit
|
|
36
|
+
</span>
|
|
37
|
+
}
|
|
38
|
+
search={
|
|
39
|
+
<SearchBar
|
|
40
|
+
value={searchInput}
|
|
41
|
+
onChange={onSearchChange}
|
|
42
|
+
onClear={onSearchClear}
|
|
43
|
+
onClearQuery={onSearchClearQuery}
|
|
44
|
+
placeholder="Search all mail"
|
|
45
|
+
size="lg"
|
|
46
|
+
/>
|
|
47
|
+
}
|
|
48
|
+
actions={
|
|
49
|
+
<>
|
|
50
|
+
<Button
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="sm"
|
|
53
|
+
icon={<SquarePen className="size-4" />}
|
|
54
|
+
title={`Compose ${tooltipForAction("compose")}`}
|
|
55
|
+
aria-label="Compose"
|
|
56
|
+
onClick={compose}
|
|
57
|
+
/>
|
|
58
|
+
<BugReportButton />
|
|
59
|
+
<AccountMenu />
|
|
60
|
+
</>
|
|
61
|
+
}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -184,26 +184,15 @@ function BriefList() {
|
|
|
184
184
|
*/
|
|
185
185
|
function BriefReading() {
|
|
186
186
|
const { conversation } = useBriefPane();
|
|
187
|
-
const {
|
|
188
|
-
onToggleIntelligence,
|
|
189
|
-
searchInput,
|
|
190
|
-
onSearchChange,
|
|
191
|
-
onSearchClear,
|
|
192
|
-
onSearchClearQuery,
|
|
193
|
-
} = useMailContext();
|
|
187
|
+
const { onToggleIntelligence } = useMailContext();
|
|
194
188
|
|
|
195
189
|
return (
|
|
196
190
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
197
191
|
<MessageToolbar
|
|
198
192
|
hasThread={Boolean(conversation)}
|
|
199
|
-
onCompose={() => undefined}
|
|
200
193
|
intelligenceOpen={false}
|
|
201
194
|
showIntelligenceToggle={false}
|
|
202
195
|
onToggleIntelligence={onToggleIntelligence}
|
|
203
|
-
searchValue={searchInput}
|
|
204
|
-
onSearchChange={onSearchChange}
|
|
205
|
-
onSearchClear={onSearchClear}
|
|
206
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
207
196
|
/>
|
|
208
197
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
209
198
|
{conversation ? (
|
|
@@ -128,26 +128,15 @@ function FlaggedListSlot() {
|
|
|
128
128
|
*/
|
|
129
129
|
function FlaggedReading() {
|
|
130
130
|
const { selectedThread } = useFlaggedPane();
|
|
131
|
-
const {
|
|
132
|
-
onToggleIntelligence,
|
|
133
|
-
searchInput,
|
|
134
|
-
onSearchChange,
|
|
135
|
-
onSearchClear,
|
|
136
|
-
onSearchClearQuery,
|
|
137
|
-
} = useMailContext();
|
|
131
|
+
const { onToggleIntelligence } = useMailContext();
|
|
138
132
|
|
|
139
133
|
return (
|
|
140
134
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
141
135
|
<MessageToolbar
|
|
142
136
|
hasThread={Boolean(selectedThread)}
|
|
143
|
-
onCompose={() => undefined}
|
|
144
137
|
intelligenceOpen={false}
|
|
145
138
|
showIntelligenceToggle={false}
|
|
146
139
|
onToggleIntelligence={onToggleIntelligence}
|
|
147
|
-
searchValue={searchInput}
|
|
148
|
-
onSearchChange={onSearchChange}
|
|
149
|
-
onSearchClear={onSearchClear}
|
|
150
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
151
140
|
/>
|
|
152
141
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
153
142
|
{selectedThread ? (
|
|
@@ -313,15 +313,17 @@ function WiredPanel({
|
|
|
313
313
|
[updateFlags],
|
|
314
314
|
);
|
|
315
315
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
316
|
+
// Every per-sender flag toggle PATCHes the sender's address row, so none of
|
|
317
|
+
// them can be serviced until that row resolves. Leave them unwired until it
|
|
318
|
+
// does: the panel renders an unwired quick action as disabled, so the flow is
|
|
319
|
+
// never reachable in a state where a click can only fail (issue #51).
|
|
320
|
+
const canUpdateFlags = addressId !== undefined;
|
|
319
321
|
const actions: IntelligenceQuickActions = {
|
|
320
|
-
onToggleVip: handleToggleVip,
|
|
321
|
-
onToggleMute: handleToggleMute,
|
|
322
|
-
onToggleBlock: handleToggleBlock,
|
|
323
|
-
onToggleUnsubscribe: handleToggleUnsubscribe,
|
|
324
|
-
onReclassify: () => setReclassifyOpen(true),
|
|
322
|
+
onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
|
|
323
|
+
onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
|
|
324
|
+
onToggleBlock: canUpdateFlags ? handleToggleBlock : undefined,
|
|
325
|
+
onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
|
|
326
|
+
onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
|
|
325
327
|
onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
|
|
326
328
|
onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
|
|
327
329
|
};
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
useAppShellLayout,
|
|
32
32
|
} from "@remit/ui";
|
|
33
33
|
import { type ReactNode, useState } from "react";
|
|
34
|
-
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
34
|
+
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
35
35
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
36
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
37
37
|
import {
|
|
@@ -168,11 +168,15 @@ export function MailListHeader({
|
|
|
168
168
|
<MailHeader
|
|
169
169
|
title={title}
|
|
170
170
|
unreadCount={unreadCount}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
171
|
+
// Desktop mounts the app top bar, which owns search for the whole
|
|
172
|
+
// shell — the list header shows no field there, so the page never
|
|
173
|
+
// has two search inputs competing for "/" and for focus. Below
|
|
174
|
+
// desktop the header keeps a compact magnifier: on phone it opens
|
|
175
|
+
// the full-screen takeover above, on tablet it expands over the
|
|
176
|
+
// title. `isSinglePaneTier` is the same predicate the shell gates
|
|
177
|
+
// the top bar on, so the two cannot drift into zero or two fields.
|
|
175
178
|
isDesktop={false}
|
|
179
|
+
showSearch={isSinglePaneTier(tier)}
|
|
176
180
|
onMenuClick={() => layout?.openNav()}
|
|
177
181
|
searchValue={searchInput}
|
|
178
182
|
onSearchChange={onSearchChange}
|
|
@@ -202,7 +202,6 @@ interface MailboxPaneContextValue {
|
|
|
202
202
|
onToolbarDiscardDraft: () => void;
|
|
203
203
|
onToolbarMove: (destMailboxId: string) => void;
|
|
204
204
|
composeState: ReturnType<typeof useCompose>["state"];
|
|
205
|
-
openCompose: () => void;
|
|
206
205
|
closeCompose: () => void;
|
|
207
206
|
hasRemitDraftOpen: boolean;
|
|
208
207
|
// Phone actions
|
|
@@ -843,7 +842,6 @@ function MailboxPaneProvider({
|
|
|
843
842
|
onToolbarDiscardDraft: handleToolbarDiscardDraft,
|
|
844
843
|
onToolbarMove: handleToolbarMove,
|
|
845
844
|
composeState,
|
|
846
|
-
openCompose: handleNewCompose,
|
|
847
845
|
closeCompose,
|
|
848
846
|
hasRemitDraftOpen,
|
|
849
847
|
onBack: goBack,
|
|
@@ -1044,10 +1042,7 @@ function MailboxReading() {
|
|
|
1044
1042
|
onToolbarDiscardDraft,
|
|
1045
1043
|
onToolbarMove,
|
|
1046
1044
|
composeState,
|
|
1047
|
-
openCompose,
|
|
1048
1045
|
} = useMailboxPane();
|
|
1049
|
-
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
1050
|
-
useMailContext();
|
|
1051
1046
|
const tier = useLayoutTier();
|
|
1052
1047
|
const isDesktop = tier === "desktop";
|
|
1053
1048
|
const hasThread = Boolean(conversation);
|
|
@@ -1078,14 +1073,9 @@ function MailboxReading() {
|
|
|
1078
1073
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
1079
1074
|
<MessageToolbar
|
|
1080
1075
|
hasThread={hasThread}
|
|
1081
|
-
onCompose={openCompose}
|
|
1082
1076
|
intelligenceOpen={showIntelligence}
|
|
1083
1077
|
showIntelligenceToggle={isDesktop && hasThread}
|
|
1084
1078
|
onToggleIntelligence={onToggleIntelligence}
|
|
1085
|
-
searchValue={searchInput}
|
|
1086
|
-
onSearchChange={onSearchChange}
|
|
1087
|
-
onSearchClear={onSearchClear}
|
|
1088
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
1089
1079
|
onReply={hasThread ? onToolbarReply : undefined}
|
|
1090
1080
|
onReplyAll={hasThread ? onToolbarReplyAll : undefined}
|
|
1091
1081
|
onForward={hasThread ? onToolbarForward : undefined}
|
|
@@ -27,6 +27,7 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
|
27
27
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
28
28
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
29
29
|
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
30
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
30
31
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
31
32
|
|
|
32
33
|
interface ThreadMessagesData {
|
|
@@ -34,15 +35,12 @@ interface ThreadMessagesData {
|
|
|
34
35
|
[key: string]: unknown;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
pages: ThreadsListPage[];
|
|
44
|
-
pageParams: Array<string | undefined>;
|
|
45
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
40
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
41
|
+
* optimistic updater sees both.
|
|
42
|
+
*/
|
|
43
|
+
type ThreadsListData = ThreadListCache;
|
|
46
44
|
|
|
47
45
|
interface SnapshotEntry<T> {
|
|
48
46
|
queryKey: readonly unknown[];
|
|
@@ -169,26 +167,20 @@ export const MessageActionMenu = ({
|
|
|
169
167
|
},
|
|
170
168
|
);
|
|
171
169
|
|
|
172
|
-
const patchListData = (old:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
? { ...item, isRead: isReadNext }
|
|
181
|
-
: item,
|
|
182
|
-
),
|
|
183
|
-
})),
|
|
184
|
-
};
|
|
185
|
-
};
|
|
170
|
+
const patchListData = (old: unknown) =>
|
|
171
|
+
patchThreadListCache(old, (items) =>
|
|
172
|
+
items.map((item) =>
|
|
173
|
+
targetIds.has(item.messageId)
|
|
174
|
+
? { ...item, isRead: isReadNext }
|
|
175
|
+
: item,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
186
178
|
|
|
187
|
-
queryClient.setQueriesData
|
|
179
|
+
queryClient.setQueriesData(
|
|
188
180
|
{ queryKey: threadsListPrefix },
|
|
189
181
|
patchListData,
|
|
190
182
|
);
|
|
191
|
-
queryClient.setQueriesData
|
|
183
|
+
queryClient.setQueriesData(
|
|
192
184
|
{ queryKey: threadsSearchPrefix },
|
|
193
185
|
patchListData,
|
|
194
186
|
);
|
|
@@ -214,6 +206,7 @@ export const MessageActionMenu = ({
|
|
|
214
206
|
pushError({
|
|
215
207
|
title: isReadNext ? "Couldn't mark as read" : "Couldn't mark as unread",
|
|
216
208
|
detail: formatErrorDetail(err),
|
|
209
|
+
error: err,
|
|
217
210
|
});
|
|
218
211
|
},
|
|
219
212
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
|
|
|
12
12
|
import { MailActionToolbar } from "@remit/ui";
|
|
13
13
|
import React, { createElement } from "react";
|
|
14
14
|
import { renderToString } from "react-dom/server";
|
|
15
|
+
import { MessageToolbar } from "./MessageToolbar";
|
|
15
16
|
|
|
16
17
|
// The node test loader transpiles remit-ui's `.tsx` (resolved through
|
|
17
18
|
// node_modules) with the classic JSX runtime, which references a global
|
|
@@ -55,3 +56,41 @@ describe("MailActionToolbar never disables its action buttons (#799)", () => {
|
|
|
55
56
|
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
56
57
|
});
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The reading-pane toolbar carries message context only (#49). Search, compose,
|
|
62
|
+
* bug report and the account menu moved up into the app top bar, which spans
|
|
63
|
+
* every pane. Two search fields on one screen fight over "/" and over focus, so
|
|
64
|
+
* the absence of one here is the thing worth pinning.
|
|
65
|
+
*/
|
|
66
|
+
describe("MessageToolbar carries message context only (#49)", () => {
|
|
67
|
+
const render = (hasThread: boolean): string =>
|
|
68
|
+
renderToString(
|
|
69
|
+
createElement(MessageToolbar, {
|
|
70
|
+
hasThread,
|
|
71
|
+
intelligenceOpen: false,
|
|
72
|
+
showIntelligenceToggle: true,
|
|
73
|
+
onToggleIntelligence: () => undefined,
|
|
74
|
+
}) as never,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
it("mounts no search field", () => {
|
|
78
|
+
for (const hasThread of [false, true]) {
|
|
79
|
+
assert.doesNotMatch(render(hasThread), /aria-label="Search mail"/);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("carries no global actions — compose, bug report, account", () => {
|
|
84
|
+
const html = render(true);
|
|
85
|
+
assert.doesNotMatch(html, /aria-label="Compose"/);
|
|
86
|
+
assert.doesNotMatch(html, /aria-label="Report a bug"/);
|
|
87
|
+
assert.doesNotMatch(html, /aria-label="Account menu"/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the message verbs and the intelligence toggle", () => {
|
|
91
|
+
const html = render(true);
|
|
92
|
+
assert.match(html, /aria-label="Reply"/);
|
|
93
|
+
assert.match(html, /aria-label="Star"/);
|
|
94
|
+
assert.match(html, /intelligence sidebar/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type MailAction,
|
|
4
|
-
MailActionToolbar,
|
|
5
|
-
SearchBar,
|
|
6
|
-
} from "@remit/ui";
|
|
7
|
-
import { Info, SquarePen } from "lucide-react";
|
|
1
|
+
import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
|
|
2
|
+
import { Info } from "lucide-react";
|
|
8
3
|
import { useState } from "react";
|
|
9
|
-
import { AccountMenu } from "@/auth/AccountMenu";
|
|
10
|
-
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
11
4
|
import { tooltipForAction } from "@/lib/keymap";
|
|
12
5
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
13
6
|
|
|
14
7
|
/**
|
|
15
8
|
* Message action toolbar on the pane-header datum (40px, the shared
|
|
16
|
-
* `--spacing-pane-header`).
|
|
17
|
-
* forward, then delete / move / flag — Apple-Mail-style ghost icon
|
|
18
|
-
*
|
|
19
|
-
*
|
|
9
|
+
* `--spacing-pane-header`). Everything here acts on the open message: reply /
|
|
10
|
+
* reply-all / forward, then delete / move / flag — Apple-Mail-style ghost icon
|
|
11
|
+
* buttons — and the intelligence toggle (#422).
|
|
12
|
+
*
|
|
13
|
+
* Search, compose, bug report and the account menu are not message context;
|
|
14
|
+
* they live in the app top bar above every pane (`MailTopBar`, #49).
|
|
20
15
|
*
|
|
21
16
|
* Buttons are always pressable (never `disabled`): with no thread open a press
|
|
22
17
|
* is a no-op that surfaces a one-line inline explanation rather than greying
|
|
@@ -24,7 +19,6 @@ import { MoveToTrigger } from "./MoveToTrigger";
|
|
|
24
19
|
*/
|
|
25
20
|
export interface MessageToolbarProps {
|
|
26
21
|
hasThread: boolean;
|
|
27
|
-
onCompose: () => void;
|
|
28
22
|
intelligenceOpen: boolean;
|
|
29
23
|
/**
|
|
30
24
|
* Whether the intelligence toggle is shown at all. The rail is contextual
|
|
@@ -33,12 +27,6 @@ export interface MessageToolbarProps {
|
|
|
33
27
|
*/
|
|
34
28
|
showIntelligenceToggle: boolean;
|
|
35
29
|
onToggleIntelligence: () => void;
|
|
36
|
-
searchValue: string;
|
|
37
|
-
onSearchChange: (value: string) => void;
|
|
38
|
-
/** Full clear (X button): drops the query and any selected thread (#538). */
|
|
39
|
-
onSearchClear: () => void;
|
|
40
|
-
/** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
|
|
41
|
-
onSearchClearQuery?: () => void;
|
|
42
30
|
|
|
43
31
|
/* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
|
|
44
32
|
onReply?: () => void;
|
|
@@ -71,14 +59,9 @@ const OPEN_FIRST = "Open a message first";
|
|
|
71
59
|
|
|
72
60
|
export const MessageToolbar = ({
|
|
73
61
|
hasThread,
|
|
74
|
-
onCompose,
|
|
75
62
|
intelligenceOpen,
|
|
76
63
|
showIntelligenceToggle,
|
|
77
64
|
onToggleIntelligence,
|
|
78
|
-
searchValue,
|
|
79
|
-
onSearchChange,
|
|
80
|
-
onSearchClear,
|
|
81
|
-
onSearchClearQuery,
|
|
82
65
|
onReply,
|
|
83
66
|
onReplyAll,
|
|
84
67
|
onForward,
|
|
@@ -120,27 +103,6 @@ export const MessageToolbar = ({
|
|
|
120
103
|
) : undefined
|
|
121
104
|
}
|
|
122
105
|
>
|
|
123
|
-
{/* Apple Mail geometry: search sits top-right over the message area
|
|
124
|
-
but still filters the current list. Reuses the wired SearchBar
|
|
125
|
-
(the global "/" focus shortcut + Escape handling live in it). */}
|
|
126
|
-
<div className="w-64 min-w-40 shrink">
|
|
127
|
-
<SearchBar
|
|
128
|
-
value={searchValue}
|
|
129
|
-
onChange={onSearchChange}
|
|
130
|
-
onClear={onSearchClear}
|
|
131
|
-
onClearQuery={onSearchClearQuery}
|
|
132
|
-
placeholder="Search mail"
|
|
133
|
-
/>
|
|
134
|
-
</div>
|
|
135
|
-
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
136
|
-
<Button
|
|
137
|
-
variant="ghost"
|
|
138
|
-
size="sm"
|
|
139
|
-
icon={<SquarePen className="size-4" />}
|
|
140
|
-
title={`Compose ${tooltipForAction("compose")}`}
|
|
141
|
-
aria-label="Compose"
|
|
142
|
-
onClick={onCompose}
|
|
143
|
-
/>
|
|
144
106
|
{showIntelligenceToggle && (
|
|
145
107
|
<Button
|
|
146
108
|
variant="ghost"
|
|
@@ -159,8 +121,6 @@ export const MessageToolbar = ({
|
|
|
159
121
|
}
|
|
160
122
|
/>
|
|
161
123
|
)}
|
|
162
|
-
<BugReportButton />
|
|
163
|
-
<AccountMenu />
|
|
164
124
|
</MailActionToolbar>
|
|
165
125
|
);
|
|
166
126
|
};
|
|
@@ -15,12 +15,12 @@ const SEVERITY_STYLES: Record<
|
|
|
15
15
|
{ container: string; icon: string; title: string }
|
|
16
16
|
> = {
|
|
17
17
|
error: {
|
|
18
|
-
container: "border-danger/50 bg-danger
|
|
18
|
+
container: "border-danger/50 bg-danger-soft",
|
|
19
19
|
icon: "text-danger",
|
|
20
20
|
title: "text-danger",
|
|
21
21
|
},
|
|
22
22
|
warning: {
|
|
23
|
-
container: "border-warning/50 bg-warning
|
|
23
|
+
container: "border-warning/50 bg-warning-soft",
|
|
24
24
|
icon: "text-warning",
|
|
25
25
|
title: "text-warning",
|
|
26
26
|
},
|
|
@@ -61,7 +61,10 @@ export const ErrorBanner = ({
|
|
|
61
61
|
role={severity === "error" ? "alert" : "status"}
|
|
62
62
|
aria-live={severity === "error" ? "assertive" : "polite"}
|
|
63
63
|
className={cn(
|
|
64
|
-
|
|
64
|
+
// Opaque, not translucent: a banner overlaps the toolbar and the
|
|
65
|
+
// message list, and see-through text on top of see-through text is
|
|
66
|
+
// unreadable (issue #55).
|
|
67
|
+
"pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas px-3 py-2 shadow-lg",
|
|
65
68
|
styles.container,
|
|
66
69
|
)}
|
|
67
70
|
>
|